diff --git a/app/build.gradle b/app/build.gradle index 55d9f61fe..98db6dc05 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -211,6 +211,9 @@ dependencies { //JVE (Regex) (EH) compile 'ru.lanwen.verbalregex:java-verbal-expressions:1.4' + //Pin lock view + compile 'com.andrognito.pinlockview:pinlockview:1.0.1' + // Tests //Paper DB screws up tests /*testCompile 'junit:junit:4.12' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 078873d5b..8c55ef3af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,9 @@ android:name="android.permission.READ_PHONE_STATE" tools:node="remove" /> + + + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 6ec68c858..b8321b408 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -14,6 +14,8 @@ import java.io.File fun Preference.getOrDefault(): T = get() ?: defaultValue()!! +fun Preference.nullGetOrDefault(): T? = get() ?: defaultValue() + fun Preference.invert(): Boolean = getOrDefault().let { set(!it); !it } class PreferencesHelper(val context: Context) { @@ -176,4 +178,11 @@ class PreferencesHelper(val context: Context) { fun memberIdVal() = rxPrefs.getString("eh_ipb_member_id", null) fun passHashVal() = rxPrefs.getString("eh_ipb_pass_hash", null) fun igneousVal() = rxPrefs.getString("eh_igneous", null) + + //Lock + fun lockHash() = rxPrefs.getString("lock_hash", null) + + fun lockSalt() = rxPrefs.getString("lock_salt", null) + + fun lockLength() = rxPrefs.getInteger("lock_length", -1) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt index 38a4568d0..59a2c317a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt @@ -2,6 +2,15 @@ package eu.kanade.tachiyomi.ui.base.activity import android.support.v7.app.AppCompatActivity import eu.kanade.tachiyomi.util.LocaleHelper +import exh.ui.lock.lockEnabled +import exh.ui.lock.showLockActivity +import android.app.ActivityManager +import android.app.Service +import android.app.usage.UsageStats +import android.app.usage.UsageStatsManager +import android.os.Build +import java.util.* + abstract class BaseActivity : AppCompatActivity(), ActivityMixin { @@ -23,4 +32,48 @@ abstract class BaseActivity : AppCompatActivity(), ActivityMixin { super.onPause() } + var willLock = false + var disableLock = false + override fun onRestart() { + super.onRestart() + if(willLock && lockEnabled() && !disableLock) { + showLockActivity(this) + } + + willLock = false + } + + override fun onStop() { + super.onStop() + tryLock() + } + + fun tryLock() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val mUsageStatsManager = getSystemService("usagestats") as UsageStatsManager + val time = System.currentTimeMillis() + // We get usage stats for the last 20 seconds + val stats = mUsageStatsManager.queryUsageStats(UsageStatsManager.INTERVAL_DAILY, time - 1000 * 20, time) + // Sort the stats by the last time used + if (stats != null) { + val mySortedMap = TreeMap() + for (usageStats in stats) { + mySortedMap.put(usageStats.lastTimeUsed, usageStats) + } + if (!mySortedMap.isEmpty()) { + if(mySortedMap[mySortedMap.lastKey()]?.packageName != packageName) { + willLock = true + } + } + } + } else { + val am = getSystemService(Service.ACTIVITY_SERVICE) as ActivityManager + val tasks: List + tasks = am.getRunningTasks(1) + val running = tasks[0] + if (running.topActivity.packageName != packageName) { + willLock = true + } + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 4bda35e74..3ab75713e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -19,6 +19,9 @@ import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersFragment import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadFragment import eu.kanade.tachiyomi.ui.setting.SettingsActivity import exh.ui.batchadd.BatchAddFragment +import exh.ui.lock.lockEnabled +import exh.ui.lock.notifyLockSecurity +import exh.ui.lock.showLockActivity import exh.ui.migration.LibraryMigrationManager import exh.ui.migration.SourceMigrator import exh.ui.migration.UrlMigrator @@ -90,9 +93,12 @@ class MainActivity : BaseActivity() { } if (savedState == null) { + //Show lock + if(lockEnabled(preferences)) { + showLockActivity(this) + } //Perform source migration SourceMigrator().tryMigrationWithDialog(this, { - // Set start screen try { setSelectedDrawerItem(startScreenId) @@ -114,6 +120,9 @@ class MainActivity : BaseActivity() { //Migrate URLs if necessary UrlMigrator().tryMigration() + + //Check lock security + notifyLockSecurity(this) }) } } diff --git a/app/src/main/java/exh/ui/intercept/InterceptActivity.kt b/app/src/main/java/exh/ui/intercept/InterceptActivity.kt index fa1a7e605..d46811b7d 100644 --- a/app/src/main/java/exh/ui/intercept/InterceptActivity.kt +++ b/app/src/main/java/exh/ui/intercept/InterceptActivity.kt @@ -43,7 +43,7 @@ class InterceptActivity : BaseActivity() { .canceledOnTouchOutside(true) .cancelListener { onBackPressed() } .positiveText("Ok") - .onPositive { materialDialog, dialogAction -> onBackPressed() } + .onPositive { _, _ -> onBackPressed() } .dismissListener { onBackPressed() } .show() } diff --git a/app/src/main/java/exh/ui/lock/LockActivity.kt b/app/src/main/java/exh/ui/lock/LockActivity.kt new file mode 100644 index 000000000..e1730e574 --- /dev/null +++ b/app/src/main/java/exh/ui/lock/LockActivity.kt @@ -0,0 +1,60 @@ +package exh.ui.lock + +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.andrognito.pinlockview.PinLockListener +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.nullGetOrDefault +import eu.kanade.tachiyomi.ui.base.activity.BaseActivity +import kotlinx.android.synthetic.main.activity_lock.* +import uy.kohesive.injekt.injectLazy + +class LockActivity : BaseActivity() { + + val prefs: PreferencesHelper by injectLazy() + + override fun onCreate(savedInstanceState: Bundle?) { + disableLock = true + + setTheme(R.style.Theme_Tachiyomi_Dark) + super.onCreate(savedInstanceState) + + if(!lockEnabled(prefs)) { + finish() + return + } + + setContentView(R.layout.activity_lock) + + pin_lock_view.attachIndicatorDots(indicator_dots) + + pin_lock_view.pinLength = prefs.lockLength().nullGetOrDefault()!! + pin_lock_view.setPinLockListener(object : PinLockListener { + override fun onEmpty() {} + + override fun onComplete(pin: String) { + if(sha512(pin, prefs.lockSalt().nullGetOrDefault()!!) == prefs.lockHash().nullGetOrDefault()) { + //Yay! + finish() + } else { + MaterialDialog.Builder(this@LockActivity) + .title("PIN code incorrect") + .content("The PIN code you entered is incorrect. Please try again.") + .cancelable(true) + .canceledOnTouchOutside(true) + .positiveText("Ok") + .autoDismiss(true) + .show() + pin_lock_view.resetPinLockView() + } + } + + override fun onPinChange(pinLength: Int, intermediatePin: String?) {} + }) + } + + override fun onBackPressed() { + moveTaskToBack(true) + } +} diff --git a/app/src/main/java/exh/ui/lock/LockPreference.kt b/app/src/main/java/exh/ui/lock/LockPreference.kt new file mode 100644 index 000000000..4f6aa09b4 --- /dev/null +++ b/app/src/main/java/exh/ui/lock/LockPreference.kt @@ -0,0 +1,84 @@ +package exh.ui.lock + +import android.content.Context +import android.support.v7.preference.Preference +import android.text.InputType +import android.util.AttributeSet +import com.afollestad.materialdialogs.MaterialDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import uy.kohesive.injekt.injectLazy +import java.math.BigInteger +import java.security.SecureRandom + +class LockPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + Preference(context, attrs) { + + val secureRandom by lazy { SecureRandom() } + + val prefs: PreferencesHelper by injectLazy() + + override fun onAttached() { + super.onAttached() + updateSummary() + } + + fun updateSummary() { + if(lockEnabled(prefs)) { + summary = "Application is locked" + } else { + summary = "Application is not locked, tap to lock" + } + } + + override fun onClick() { + super.onClick() + if(!notifyLockSecurity(context)) { + MaterialDialog.Builder(context) + .title("Lock application") + .content("Enter a pin to lock the application. Enter nothing to disable the pin lock.") + .inputRangeRes(0, 10, R.color.material_red_500) + .inputType(InputType.TYPE_CLASS_NUMBER) + .input("Password", "", { _, c -> + val progressDialog = MaterialDialog.Builder(context) + .title("Saving password") + .progress(true, 0) + .cancelable(false) + .show() + Observable.fromCallable { + savePassword(c.toString()) + }.observeOn(Schedulers.computation()) + .subscribeOn(AndroidSchedulers.mainThread()) + .subscribe { + progressDialog.dismiss() + updateSummary() + } + }) + .autoDismiss(true) + .cancelable(true) + .canceledOnTouchOutside(true) + .show() + } + } + + fun savePassword(password: String) { + val salt: String? + val hash: String? + val length: Int + if(password.isEmpty()) { + salt = null + hash = null + length = -1 + } else { + salt = BigInteger(130, secureRandom).toString(32) + hash = sha512(password, salt) + length = password.length + } + prefs.lockSalt().set(salt) + prefs.lockHash().set(hash) + prefs.lockLength().set(length) + } +} diff --git a/app/src/main/java/exh/ui/lock/LockUtils.kt b/app/src/main/java/exh/ui/lock/LockUtils.kt new file mode 100644 index 000000000..37edae2bf --- /dev/null +++ b/app/src/main/java/exh/ui/lock/LockUtils.kt @@ -0,0 +1,91 @@ +package exh.ui.lock + +import android.annotation.TargetApi +import android.app.Activity +import android.app.AppOpsManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import com.afollestad.materialdialogs.MaterialDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.nullGetOrDefault +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.security.MessageDigest +import kotlin.experimental.and + + +/** + * Password hashing utils + */ + +/** + * Yes, I know SHA512 is fast, but bcrypt on mobile devices is too slow apparently + */ +fun sha512(passwordToHash: String, salt: String): String { + val md = MessageDigest.getInstance("SHA-512") + md.update(salt.toByteArray(charset("UTF-8"))) + val bytes = md.digest(passwordToHash.toByteArray(charset("UTF-8"))) + val sb = StringBuilder() + for (i in bytes.indices) { + sb.append(Integer.toString((bytes[i] and 0xff.toByte()) + 0x100, 16).substring(1)) + } + return sb.toString() +} + +/** + * Check if lock is enabled + */ +fun lockEnabled(prefs: PreferencesHelper = Injekt.get()) + = prefs.lockHash().nullGetOrDefault() != null + && prefs.lockSalt().nullGetOrDefault() != null + && prefs.lockLength().nullGetOrDefault() != -1 + +/** + * Lock the screen + */ +fun showLockActivity(activity: Activity) { + activity.startActivity(Intent(activity, LockActivity::class.java)) +} + +/** + * Check if the lock will function properly + * + * @return true if action is required, false if lock is working properly + */ +fun notifyLockSecurity(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && !hasAccessToUsageStats(context)) { + MaterialDialog.Builder(context) + .title("Permission required") + .content("${context.getString(R.string.app_name)} requires the usage stats permission to detect when you leave the app. " + + "This is required for the application lock to function properly. " + + "Press OK to grant this permission now.") + .negativeText("Cancel") + .positiveText("Ok") + .onPositive { _, _ -> + context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) + } + .autoDismiss(true) + .cancelable(false) + .show() + return true + } else { + return false + } +} + +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +fun hasAccessToUsageStats(context: Context): Boolean { + try { + val packageManager = context.packageManager + val applicationInfo = packageManager.getApplicationInfo(context.packageName, 0) + val appOpsManager = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + val mode = appOpsManager.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, applicationInfo.uid, applicationInfo.packageName) + return (mode == AppOpsManager.MODE_ALLOWED) + } catch (e: PackageManager.NameNotFoundException) { + return false + } +} diff --git a/app/src/main/res/layout/activity_lock.xml b/app/src/main/res/layout/activity_lock.xml new file mode 100644 index 000000000..e6898f560 --- /dev/null +++ b/app/src/main/res/layout/activity_lock.xml @@ -0,0 +1,29 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_general.xml b/app/src/main/res/xml/pref_general.xml index 23285b8ad..95dc6702e 100644 --- a/app/src/main/res/xml/pref_general.xml +++ b/app/src/main/res/xml/pref_general.xml @@ -63,6 +63,11 @@ android:key="@string/pref_update_only_non_completed_key" android:title="@string/pref_update_only_non_completed" /> + + \ No newline at end of file