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" />
</intent-filter>
</activity>
<activity android:name=".ui.search.global.GlobalSearchActivity"
<activity
android:name=".ui.search.global.GlobalSearchActivity"
android:label="@string/search" />
<activity
android:name=".ui.utils.protect.ProtectActivity"
android:windowSoftInputMode="adjustResize" />
<service
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)
)
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>
get() = sourcesOrderStr?.split('|')?.mapNotNull(String::toIntOrNull).orEmpty()

View File

@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.domain.history
interface OnHistoryChangeListener {
fun interface OnHistoryChangeListener {
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)
}

View File

@@ -61,18 +61,29 @@ class TextInputDialog private constructor(private val delegate: AlertDialog) :
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, _ ->
listener(dialog, view.inputEdit.text?.toString().orEmpty())
}
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)
return this
}
fun setOnCancelListener(listener: DialogInterface.OnCancelListener): Builder {
delegate.setOnCancelListener(listener)
return this
}
fun create() =
TextInputDialog(delegate.create())

View File

@@ -54,7 +54,7 @@ class CheckableImageView @JvmOverloads constructor(
return state
}
interface OnCheckedChangeListener {
fun interface OnCheckedChangeListener {
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.SettingsActivity
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.resolveDp
import java.io.Closeable
@@ -71,6 +72,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} ?: run {
openDefaultSection()
}
if (AppProtectHelper.check(this)) {
return
}
TrackWorker.setup(applicationContext)
AppUpdateChecker(this).invoke()
}
@@ -78,6 +82,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onDestroy() {
closeable?.close()
settings.unsubscribe(this)
AppProtectHelper.lock()
super.onDestroy()
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.ui.list.filter
import org.koitharu.kotatsu.core.model.MangaFilter
interface OnFilterChangedListener {
fun interface OnFilterChangedListener {
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)
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.ui.reader.thumbnails
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
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.text.InputType
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arrayMapOf
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
import androidx.preference.SeekBarPreference
import androidx.preference.*
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BasePreferenceFragment
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.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.ui.tracker.TrackWorker
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.md5
import java.io.File
@@ -50,6 +52,8 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
findPreference<SwitchPreference>(R.string.key_protect_app)?.isChecked =
!settings.appPassword.isNullOrEmpty()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
@@ -114,6 +118,14 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
.show()
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)
}
}
@@ -122,6 +134,56 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
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 {
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.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import java.net.SocketTimeoutException
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 EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is SocketTimeoutException -> resources.getString(R.string.network_error)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
else -> message ?: resources.getString(R.string.error_occurred)
}

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.utils.ext
import android.net.Uri
import java.math.BigInteger
import java.net.URLEncoder
import java.security.MessageDigest
import java.util.*
fun String.longHashCode(): Long {
@@ -100,4 +102,11 @@ fun ByteArray.byte2HexFormatted(): String? {
}
}
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_height="wrap_content"
android:imeOptions="actionDone"
android:maxLines="1"
android:singleLine="true"
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="track_sources">Проверять обновления манги</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>

View File

@@ -23,6 +23,8 @@
<string name="key_notifications_vibrate">notifications_vibrate</string>
<string name="key_notifications_light">notifications_light</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_ssl">ssl</string>

View File

@@ -148,4 +148,10 @@
<string name="feed_will_update_soon">Feed update will start soon</string>
<string name="track_sources">Check updates for manga</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>

View File

@@ -48,6 +48,13 @@
android:title="@string/history_and_cache"
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
android:defaultValue="@array/values_reader_switchers_default"
android:entries="@array/reader_switchers"
@@ -57,7 +64,7 @@
app:allowDividerAbove="true"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
<SwitchPreference
android:defaultValue="false"
android:key="@string/key_reader_animation"
android:title="@string/pages_animation"