commit d630e5c420b96dc0c5b3aea3f039d2860a8784e2
parent 692bc9309b27b9a2d5b3ac081db2e3296ccb7e2b
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat, 13 Apr 2024 22:10:59 +0200

feat: app lock

Diffstat:
Mapp/src/main/AndroidManifest.xml | 5+++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/bridge/BiometricPromptActivity.kt | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/res/values/themes.xml | 7+++++++
Mcommon/src/main/assets/lang/en_US.json | 22+++++++++++++++-------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt | 7+++++--
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt | 2+-
Acore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppLock.kt | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppPasscode.kt | 107-------------------------------------------------------------------------------
8 files changed, 245 insertions(+), 117 deletions(-)

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> + <uses-permission android:name="android.permission.USE_BIOMETRIC" /> <queries> <package android:name="com.snapchat.android" /> @@ -59,6 +60,10 @@ android:theme="@android:style/Theme.NoDisplay" android:excludeFromRecents="true" android:exported="true" /> + <activity android:name=".bridge.BiometricPromptActivity" + android:theme="@style/BiometricPromptTheme" + android:excludeFromRecents="true" + android:exported="true" /> <receiver android:name=".messaging.StreaksReminder" /> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BiometricPromptActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BiometricPromptActivity.kt @@ -0,0 +1,57 @@ +package me.rhunk.snapenhance.bridge + +import android.content.Intent +import android.hardware.biometrics.BiometricManager +import android.hardware.biometrics.BiometricPrompt +import android.os.Build +import android.os.Bundle +import android.os.CancellationSignal +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import me.rhunk.snapenhance.SharedContextHolder +import java.util.concurrent.Executors + +class BiometricPromptActivity: ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + fun cancel() { + setResult(RESULT_CANCELED, Intent()) + finish() + } + + val remoteSideContext = SharedContextHolder.remote(this) + + BiometricPrompt.Builder(this@BiometricPromptActivity) + .setTitle(remoteSideContext.translation["biometric_auth.title"]) + .setSubtitle(remoteSideContext.translation["biometric_auth.subtitle"]) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + @Suppress("DEPRECATION") + setDeviceCredentialAllowed(true) + } + } + .build().authenticate( + CancellationSignal().apply { + setOnCancelListener { + cancel() + } + }, + Executors.newSingleThreadExecutor(), + object: BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { + cancel() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { + setResult(RESULT_OK, Intent()) + finish() + } + } + ) + + setContent {} + } +}+ \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml @@ -9,4 +9,11 @@ <item name="android:editTextColor">@color/primaryText</item> <item name="android:alertDialogTheme">@android:style/Theme.DeviceDefault.Dialog.Alert</item> </style> + <style name="BiometricPromptTheme" parent="AppTheme"> + <item name="android:windowBackground">@android:color/transparent</item> + <item name="android:windowIsTranslucent">true</item> + <item name="android:windowTranslucentStatus">true</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:backgroundDimEnabled">false</item> + </style> </resources> \ No newline at end of file diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -846,13 +846,15 @@ "name": "Edit Messages", "description": "Allows you to edit messages in conversations" }, - "app_passcode": { - "name": "App Passcode", - "description": "Sets a passcode to lock the app" - }, - "app_lock_on_resume": { - "name": "App Lock On Resume", - "description": "Locks the app when it's reopened" + "app_lock": { + "name": "App Lock", + "description": "Prevents access to Snapchat without a passcode", + "properties": { + "lock_on_resume": { + "name": "Lock On Resume", + "description": "Locks the app when it's reopened" + } + } }, "infinite_story_boost": { "name": "Infinite Story Boost", @@ -1318,6 +1320,12 @@ "notification_text": "You will lose your Streak with {friend} in {hoursLeft} hours" }, + "biometric_auth": { + "unlock_button": "Unlock", + "title": "Unlock Snapchat", + "subtitle": "Please authenticate to unlock Snapchat" + }, + "end_to_end_encryption": { "toolbox": { "no_shared_key": "You don't have a shared secret with this friend yet. Click below to initiate a new one.", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -22,6 +22,10 @@ class Experimental : ConfigContainer() { val autoBackupCurrentAccount = boolean("auto_backup_current_account", defaultValue = true) } + class AppLockConfig: ConfigContainer(hasGlobalState = true) { + val lockOnResume = boolean("lock_on_resume", defaultValue = true) + } + val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() } val sessionEvents = container("session_events", SessionEventsConfig()) { requireRestart(); nativeHooks() } val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() } @@ -32,8 +36,7 @@ class Experimental : ConfigContainer() { val callRecorder = boolean("call_recorder") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } val accountSwitcher = container("account_switcher", AccountSwitcherConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val editMessage = boolean("edit_message") { requireRestart(); addNotices(FeatureNotice.BAN_RISK) } - val appPasscode = string("app_passcode") - val appLockOnResume = boolean("app_lock_on_resume") + val appLock = container("app_lock", AppLockConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val infiniteStoryBoost = boolean("infinite_story_boost") val meoPasscodeBypass = boolean("meo_passcode_bypass") val noFriendScoreDelay = boolean("no_friend_score_delay") { requireRestart()} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt @@ -84,7 +84,7 @@ class FeatureManager( BypassVideoLengthRestriction(), MediaQualityLevelOverride(), MeoPasscodeBypass(), - AppPasscode(), + AppLock(), CameraTweaks(), InfiniteStoryBoost(), AmoledDarkMode(), diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppLock.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppLock.kt @@ -0,0 +1,153 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import android.view.View +import android.widget.FrameLayout +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.unit.dp +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.ui.AppMaterialTheme +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import kotlin.random.Random + +class AppLock : Feature("AppLock", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private var isUnlockRequested = false + + private val rootContentView get() = context.mainActivity!!.findViewById<FrameLayout>(android.R.id.content) + private val requestCode = Random.nextInt(100, 65535) + + private fun hideRootView() { + rootContentView.addForegroundDrawable("locked_overlay", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + paint.color = 0xFF000000.toInt() + canvas.drawRect(0F, 0F, canvas.width.toFloat(), canvas.height.toFloat(), paint) + } + })) + } + + private fun requestUnlock() { + isUnlockRequested = true + context.mainActivity!!.startActivityForResult(Intent().apply { + component = ComponentName(Constants.SE_PACKAGE_NAME, "me.rhunk.snapenhance.bridge.BiometricPromptActivity") + }, requestCode) + } + + private fun lock(prompt: Boolean = true) { + isUnlockRequested = true + hideRootView() + + val lockedView = rootContentView.findViewWithTag<View>("locked_view") ?: createComposeView(rootContentView.context) { + AppMaterialTheme(isDarkTheme = true) { + Surface( + color = MaterialTheme.colorScheme.surface, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + imageVector = Icons.Default.Lock, + contentDescription = "Lock", + modifier = Modifier.size(100.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) + ) + Button(onClick = { + requestUnlock() + }) { + Text(remember { context.translation["biometric_auth.unlock_button"] }) + } + } + } + } + } + }.apply { + tag = "locked_view" + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + rootContentView.addView(this) + } + + rootContentView.postDelayed({ + rootContentView.children().forEach { it.visibility = View.GONE } + lockedView.visibility = View.VISIBLE + rootContentView.removeForegroundDrawable("locked_overlay") + }, 500) + + if (prompt) { + requestUnlock() + } + } + + private fun unlock() { + rootContentView.apply { + removeForegroundDrawable("locked_overlay") + children().forEach { it.visibility = View.VISIBLE } + visibility = View.VISIBLE + findViewWithTag<View>("locked_view")?.visibility = View.GONE + postDelayed({ + isUnlockRequested = false + }, 1000) + } + } + + override fun onActivityCreate() { + if (context.config.experimental.appLock.globalState != true) return + + Activity::class.java.apply { + if (context.config.experimental.appLock.lockOnResume.get()) { + hook("onResume", HookStage.BEFORE) { param -> + if (param.thisObject<Activity>().packageName != Constants.SNAPCHAT_PACKAGE_NAME) return@hook + if (isUnlockRequested) return@hook + lock(prompt = true) + } + hook("onPause", HookStage.BEFORE) { param -> + if (param.thisObject<Activity>().packageName != Constants.SNAPCHAT_PACKAGE_NAME) return@hook + if (isUnlockRequested) return@hook + hideRootView() + } + } + } + + context.event.subscribe(ActivityResultEvent::class) { event -> + if (event.requestCode != requestCode) return@subscribe + if (event.resultCode == Activity.RESULT_OK) { + unlock() + return@subscribe + } + lock(prompt = false) + } + lock() + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppPasscode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppPasscode.kt @@ -1,106 +0,0 @@ -package me.rhunk.snapenhance.core.features.impl.experiments - -import android.annotation.SuppressLint -import android.content.Context -import android.os.Build -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import me.rhunk.snapenhance.core.features.Feature -import me.rhunk.snapenhance.core.features.FeatureLoadParams -import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper - -//TODO: fingerprint unlock -class AppPasscode : Feature("App Passcode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - private var isLocked = false - - private fun setActivityVisibility(isVisible: Boolean) { - context.mainActivity?.let { - it.window.attributes = it.window.attributes.apply { alpha = if (isVisible) 1.0F else 0.0F } - } - } - - fun lock() { - if (isLocked) return - isLocked = true - val passcode by context.config.experimental.appPasscode.also { - if (it.getNullable()?.isEmpty() != false) return - } - val isDigitPasscode = passcode.all { it.isDigit() } - - val mainActivity = context.mainActivity!! - setActivityVisibility(false) - - val prompt = ViewAppearanceHelper.newAlertDialogBuilder(mainActivity) - val createPrompt = { - val alertDialog = prompt.create() - val textView = EditText(mainActivity) - textView.setSingleLine() - textView.inputType = if (isDigitPasscode) { - (InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD) - } else { - (InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) - } - textView.hint = "Code :" - textView.setPadding(100, 100, 100, 100) - - textView.addTextChangedListener(object: TextWatcher { - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - if (s.contentEquals(passcode)) { - alertDialog.dismiss() - isLocked = false - setActivityVisibility(true) - } - } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun afterTextChanged(s: Editable?) {} - }) - - alertDialog.setView(textView) - - textView.viewTreeObserver.addOnWindowFocusChangeListener { hasFocus -> - if (!hasFocus) return@addOnWindowFocusChangeListener - val imm = mainActivity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(textView, InputMethodManager.SHOW_IMPLICIT) - } - - alertDialog.window?.let { - it.attributes.verticalMargin = -0.18F - } - - alertDialog.show() - textView.requestFocus() - } - - prompt.setOnCancelListener { - createPrompt() - } - - createPrompt() - } - - @SuppressLint("MissingPermission") - override fun onActivityCreate() { - if (!context.database.hasArroyo()) return - - context.runOnUiThread { - lock() - } - - if (!context.config.experimental.appLockOnResume.get()) return - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - context.mainActivity?.registerActivityLifecycleCallbacks(object: android.app.Application.ActivityLifecycleCallbacks { - override fun onActivityPaused(activity: android.app.Activity) { lock() } - override fun onActivityResumed(activity: android.app.Activity) {} - override fun onActivityStarted(activity: android.app.Activity) {} - override fun onActivityDestroyed(activity: android.app.Activity) {} - override fun onActivitySaveInstanceState(activity: android.app.Activity, outState: android.os.Bundle) {} - override fun onActivityStopped(activity: android.app.Activity) {} - override fun onActivityCreated(activity: android.app.Activity, savedInstanceState: android.os.Bundle?) {} - }) - } - } -}- \ No newline at end of file