Password protection

This commit is contained in:
Koitharu
2020-10-11 16:45:29 +03:00
parent 55fc1aeadd
commit e9bce8ef15
25 changed files with 357 additions and 17 deletions

View File

@@ -69,8 +69,12 @@
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".ui.search.global.GlobalSearchActivity" <activity
android:name=".ui.search.global.GlobalSearchActivity"
android:label="@string/search" /> android:label="@string/search" />
<activity
android:name=".ui.utils.protect.ProtectActivity"
android:windowSoftInputMode="adjustResize" />
<service <service
android:name=".ui.download.DownloadService" android:name=".ui.download.DownloadService"

View File

@@ -0,0 +1,3 @@
package org.koitharu.kotatsu.core.exceptions
class WrongPasswordException : SecurityException()

View File

@@ -92,7 +92,13 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
setOf(TRACK_FAVOURITES, TRACK_HISTORY) setOf(TRACK_FAVOURITES, TRACK_HISTORY)
) )
private var sourcesOrderStr by NullableStringPreferenceDelegate(resources.getString(R.string.key_sources_order)) var appPassword by NullableStringPreferenceDelegate(
resources.getString(R.string.key_app_password)
)
private var sourcesOrderStr by NullableStringPreferenceDelegate(
resources.getString(R.string.key_sources_order)
)
var sourcesOrder: List<Int> var sourcesOrder: List<Int>
get() = sourcesOrderStr?.split('|')?.mapNotNull(String::toIntOrNull).orEmpty() get() = sourcesOrderStr?.split('|')?.mapNotNull(String::toIntOrNull).orEmpty()

View File

@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.domain.history package org.koitharu.kotatsu.domain.history
interface OnHistoryChangeListener { fun interface OnHistoryChangeListener {
fun onHistoryChanged() fun onHistoryChanged()
} }

View File

@@ -78,7 +78,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
} }
interface OnStorageSelectListener { fun interface OnStorageSelectListener {
fun onStorageSelected(file: File) fun onStorageSelected(file: File)
} }

View File

@@ -61,18 +61,29 @@ class TextInputDialog private constructor(private val delegate: AlertDialog) :
return this return this
} }
fun setPositiveButton(@StringRes textId: Int, listener: (DialogInterface, String) -> Unit): Builder { fun setPositiveButton(
@StringRes textId: Int,
listener: (DialogInterface, String) -> Unit
): Builder {
delegate.setPositiveButton(textId) { dialog, _ -> delegate.setPositiveButton(textId) { dialog, _ ->
listener(dialog, view.inputEdit.text?.toString().orEmpty()) listener(dialog, view.inputEdit.text?.toString().orEmpty())
} }
return this return this
} }
fun setNegativeButton(@StringRes textId: Int, listener: DialogInterface.OnClickListener? = null): Builder { fun setNegativeButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder {
delegate.setNegativeButton(textId, listener) delegate.setNegativeButton(textId, listener)
return this return this
} }
fun setOnCancelListener(listener: DialogInterface.OnCancelListener): Builder {
delegate.setOnCancelListener(listener)
return this
}
fun create() = fun create() =
TextInputDialog(delegate.create()) TextInputDialog(delegate.create())

View File

@@ -54,7 +54,7 @@ class CheckableImageView @JvmOverloads constructor(
return state return state
} }
interface OnCheckedChangeListener { fun interface OnCheckedChangeListener {
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
} }

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.ui.search.SearchHelper
import org.koitharu.kotatsu.ui.settings.AppUpdateChecker import org.koitharu.kotatsu.ui.settings.AppUpdateChecker
import org.koitharu.kotatsu.ui.settings.SettingsActivity import org.koitharu.kotatsu.ui.settings.SettingsActivity
import org.koitharu.kotatsu.ui.tracker.TrackWorker import org.koitharu.kotatsu.ui.tracker.TrackWorker
import org.koitharu.kotatsu.ui.utils.protect.AppProtectHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import java.io.Closeable import java.io.Closeable
@@ -71,6 +72,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} ?: run { } ?: run {
openDefaultSection() openDefaultSection()
} }
if (AppProtectHelper.check(this)) {
return
}
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
AppUpdateChecker(this).invoke() AppUpdateChecker(this).invoke()
} }
@@ -78,6 +82,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onDestroy() { override fun onDestroy() {
closeable?.close() closeable?.close()
settings.unsubscribe(this) settings.unsubscribe(this)
AppProtectHelper.lock()
super.onDestroy() super.onDestroy()
} }

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.ui.list.filter
import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.model.MangaFilter
interface OnFilterChangedListener { fun interface OnFilterChangedListener {
fun onFilterChanged(filter: MangaFilter) fun onFilterChanged(filter: MangaFilter)
} }

View File

@@ -55,7 +55,7 @@ class ChaptersDialog : AlertDialogFragment(R.layout.dialog_chapters),
} }
} }
interface OnChapterChangeListener { fun interface OnChapterChangeListener {
fun onChapterChanged(chapter: MangaChapter) fun onChapterChanged(chapter: MangaChapter)
} }

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.ui.reader.thumbnails
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
interface OnPageSelectListener { fun interface OnPageSelectListener {
fun onPageSelected(page: MangaPage) fun onPageSelected(page: MangaPage)
} }

View File

@@ -1,26 +1,28 @@
package org.koitharu.kotatsu.ui.settings package org.koitharu.kotatsu.ui.settings
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.text.InputType
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arrayMapOf import androidx.collection.arrayMapOf
import androidx.preference.MultiSelectListPreference import androidx.preference.*
import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar
import androidx.preference.PreferenceScreen
import androidx.preference.SeekBarPreference
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BasePreferenceFragment import org.koitharu.kotatsu.ui.common.BasePreferenceFragment
import org.koitharu.kotatsu.ui.common.dialog.StorageSelectDialog import org.koitharu.kotatsu.ui.common.dialog.StorageSelectDialog
import org.koitharu.kotatsu.ui.common.dialog.TextInputDialog
import org.koitharu.kotatsu.ui.list.ListModeSelectDialog import org.koitharu.kotatsu.ui.list.ListModeSelectDialog
import org.koitharu.kotatsu.ui.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.ui.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.ui.tracker.TrackWorker import org.koitharu.kotatsu.ui.tracker.TrackWorker
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.md5
import java.io.File import java.io.File
@@ -50,6 +52,8 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
summary = settings.getStorageDir(context)?.getStorageName(context) summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available) ?: getString(R.string.not_available)
} }
findPreference<SwitchPreference>(R.string.key_protect_app)?.isChecked =
!settings.appPassword.isNullOrEmpty()
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
@@ -114,6 +118,14 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
.show() .show()
true true
} }
getString(R.string.key_protect_app) -> {
if ((preference as? SwitchPreference ?: return false).isChecked) {
enableAppProtection(preference)
} else {
settings.appPassword = null
}
true
}
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
@@ -122,6 +134,56 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
settings.setStorageDir(context ?: return, file) settings.setStorageDir(context ?: return, file)
} }
private fun enableAppProtection(preference: SwitchPreference) {
val ctx = preference.context ?: return
val cancelListener =
object : DialogInterface.OnCancelListener, DialogInterface.OnClickListener {
override fun onCancel(dialog: DialogInterface?) {
settings.appPassword = null
preference.isChecked = false
preference.isEnabled = true
}
override fun onClick(dialog: DialogInterface?, which: Int) = onCancel(dialog)
}
preference.isEnabled = false
TextInputDialog.Builder(ctx)
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
.setHint(R.string.enter_password)
.setNegativeButton(android.R.string.cancel, cancelListener)
.setOnCancelListener(cancelListener)
.setPositiveButton(android.R.string.ok) { d, password ->
if (password.isBlank()) {
cancelListener.onCancel(d)
return@setPositiveButton
}
TextInputDialog.Builder(ctx)
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
.setHint(R.string.repeat_password)
.setNegativeButton(android.R.string.cancel, cancelListener)
.setOnCancelListener(cancelListener)
.setPositiveButton(android.R.string.ok) { d2, password2 ->
if (password == password2) {
settings.appPassword = password.md5()
preference.isChecked = true
preference.isEnabled = true
} else {
cancelListener.onCancel(d2)
Snackbar.make(
listView,
R.string.passwords_mismatch,
Snackbar.LENGTH_SHORT
).show()
}
}.setTitle(preference.title)
.create()
.show()
}.setTitle(preference.title)
.create()
.show()
}
private companion object { private companion object {
val LIST_MODES = arrayMapOf( val LIST_MODES = arrayMapOf(

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.ui.utils.protect
import android.app.Activity
import android.content.Intent
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.ui.list.MainActivity
object AppProtectHelper : KoinComponent {
val settings by inject<AppSettings>()
private var isUnlocked = settings.appPassword.isNullOrEmpty()
fun unlock(activity: Activity) {
isUnlocked = true
with(activity) {
startActivity(Intent(this, MainActivity::class.java))
finishAfterTransition()
}
}
fun lock() {
isUnlocked = settings.appPassword.isNullOrEmpty()
}
fun check(activity: Activity): Boolean {
return if (!isUnlocked) {
with(activity) {
startActivity(ProtectActivity.newIntent(this))
finishAfterTransition()
}
true
} else {
false
}
}
}

View File

@@ -0,0 +1,80 @@
package org.koitharu.kotatsu.ui.utils.protect
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import kotlinx.android.synthetic.main.activity_protect.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class ProtectActivity : BaseActivity(), ProtectView, TextView.OnEditorActionListener, TextWatcher {
private val presenter by moxyPresenter(factory = ::ProtectPresenter)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_protect)
edit_password.setOnEditorActionListener(this)
edit_password.addTextChangedListener(this)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(R.drawable.ic_cross)
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_protect, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_done -> {
presenter.tryUnlock(edit_password.text?.toString().orEmpty())
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
return if (actionId == EditorInfo.IME_ACTION_DONE) {
presenter.tryUnlock(edit_password.text?.toString().orEmpty())
true
} else {
false
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
layout_password.error = null
}
override fun onUnlockSuccess() {
AppProtectHelper.unlock(this)
}
override fun onError(e: Throwable) {
layout_password.error = e.getDisplayMessage(resources)
}
override fun onLoadingStateChanged(isLoading: Boolean) {
layout_password.isEnabled = !isLoading
}
companion object {
fun newIntent(context: Context) = Intent(context, ProtectActivity::class.java)
}
}

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.ui.utils.protect
import kotlinx.coroutines.delay
import org.koin.core.component.inject
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.utils.ext.md5
class ProtectPresenter : BasePresenter<ProtectView>() {
private val settings by inject<AppSettings>()
fun tryUnlock(password: String) {
launchLoadingJob {
val passwordHash = password.md5()
val appPasswordHash = settings.appPassword
if (passwordHash == appPasswordHash) {
viewState.onUnlockSuccess()
} else {
delay(PASSWORD_COMPARE_DELAY)
throw WrongPasswordException()
}
}
}
private companion object {
const val PASSWORD_COMPARE_DELAY = 1_000L
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.ui.utils.protect
import moxy.viewstate.strategy.alias.SingleState
import org.koitharu.kotatsu.ui.common.BaseMvpView
interface ProtectView : BaseMvpView {
@SingleState
fun onUnlockSuccess()
}

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
inline fun <T, R> T.safe(action: T.() -> R?) = try { inline fun <T, R> T.safe(action: T.() -> R?) = try {
@@ -39,6 +40,7 @@ fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is SocketTimeoutException -> resources.getString(R.string.network_error) is SocketTimeoutException -> resources.getString(R.string.network_error)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
else -> message ?: resources.getString(R.string.error_occurred) else -> message ?: resources.getString(R.string.error_occurred)
} }

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.net.Uri import android.net.Uri
import java.math.BigInteger
import java.net.URLEncoder import java.net.URLEncoder
import java.security.MessageDigest
import java.util.* import java.util.*
fun String.longHashCode(): Long { fun String.longHashCode(): Long {
@@ -100,4 +102,11 @@ fun ByteArray.byte2HexFormatted(): String? {
} }
} }
return str.toString() return str.toString()
}
fun String.md5(): String {
val md = MessageDigest.getInstance("MD5")
return BigInteger(1, md.digest(toByteArray()))
.toString(16)
.padStart(32, '0')
} }

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorPrimary"
android:theme="@style/AppToolbarTheme">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways"
app:popupTheme="@style/AppPopupTheme" />
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="20dp"
app:boxBackgroundColor="@android:color/transparent"
app:boxBackgroundMode="filled"
app:endIconMode="password_toggle"
app:errorEnabled="true"
app:errorTextColor="@color/error">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/enter_password"
android:imeOptions="actionDone"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@@ -21,7 +21,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:imeOptions="actionDone" android:imeOptions="actionDone"
android:maxLines="1"
android:singleLine="true" android:singleLine="true"
tools:text="@tools:sample/lorem[2]" /> tools:text="@tools:sample/lorem[2]" />

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_done"
android:icon="@drawable/ic_done"
android:orderInCategory="0"
android:title="@string/done"
app:showAsAction="ifRoom|withText" />
</menu>

View File

@@ -147,4 +147,10 @@
<string name="feed_will_update_soon">Обновление скоро начнётся</string> <string name="feed_will_update_soon">Обновление скоро начнётся</string>
<string name="track_sources">Проверять обновления манги</string> <string name="track_sources">Проверять обновления манги</string>
<string name="dont_check">Не проверять</string> <string name="dont_check">Не проверять</string>
<string name="enter_password">Введите пароль</string>
<string name="wrong_password">Неверный пароль</string>
<string name="protect_application">Защитить приложение</string>
<string name="protect_application_summary">Запрашивать пароль при запуске приложения</string>
<string name="repeat_password">Повторите пароль</string>
<string name="passwords_mismatch">Пароли не совпадают</string>
</resources> </resources>

View File

@@ -23,6 +23,8 @@
<string name="key_notifications_vibrate">notifications_vibrate</string> <string name="key_notifications_vibrate">notifications_vibrate</string>
<string name="key_notifications_light">notifications_light</string> <string name="key_notifications_light">notifications_light</string>
<string name="key_reader_animation">reader_animation</string> <string name="key_reader_animation">reader_animation</string>
<string name="key_app_password">app_password</string>
<string name="key_protect_app">protect_app</string>
<string name="key_parser_domain">domain</string> <string name="key_parser_domain">domain</string>
<string name="key_parser_ssl">ssl</string> <string name="key_parser_ssl">ssl</string>

View File

@@ -148,4 +148,10 @@
<string name="feed_will_update_soon">Feed update will start soon</string> <string name="feed_will_update_soon">Feed update will start soon</string>
<string name="track_sources">Check updates for manga</string> <string name="track_sources">Check updates for manga</string>
<string name="dont_check">Don`t check</string> <string name="dont_check">Don`t check</string>
<string name="enter_password">Enter password</string>
<string name="wrong_password">Wrong password</string>
<string name="protect_application">Protect application</string>
<string name="protect_application_summary">Ask for password on application start</string>
<string name="repeat_password">Repeat password</string>
<string name="passwords_mismatch">Passwords do not match</string>
</resources> </resources>

View File

@@ -48,6 +48,13 @@
android:title="@string/history_and_cache" android:title="@string/history_and_cache"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<SwitchPreference
android:key="@string/key_protect_app"
android:persistent="false"
android:summary="@string/protect_application_summary"
android:title="@string/protect_application"
app:iconSpaceReserved="false" />
<MultiSelectListPreference <MultiSelectListPreference
android:defaultValue="@array/values_reader_switchers_default" android:defaultValue="@array/values_reader_switchers_default"
android:entries="@array/reader_switchers" android:entries="@array/reader_switchers"
@@ -57,7 +64,7 @@
app:allowDividerAbove="true" app:allowDividerAbove="true"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<SwitchPreferenceCompat <SwitchPreference
android:defaultValue="false" android:defaultValue="false"
android:key="@string/key_reader_animation" android:key="@string/key_reader_animation"
android:title="@string/pages_animation" android:title="@string/pages_animation"