commit c77d11f4a0e6dc0e58ecabf54004d719af27eaa5
parent b4f6e4f3bdeba0c48fa2eacd1a865a8717149d04
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat,  9 Mar 2024 15:03:35 +0100

feat(experimental): account switcher

Diffstat:
Aapp/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt | 1+
Mapp/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt | 1+
Acommon/src/main/aidl/me/rhunk/snapenhance/bridge/AccountStorage.aidl | 11+++++++++++
Mcommon/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl | 3+++
Mcommon/src/main/assets/lang/en_US.json | 10++++++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt | 5+++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt | 3+++
Acore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AccountSwitcher.kt | 528+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt | 25+++++++++++++++++++++++--
11 files changed, 637 insertions(+), 2 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt @@ -0,0 +1,50 @@ +package me.rhunk.snapenhance + +import android.os.ParcelFileDescriptor +import me.rhunk.snapenhance.bridge.AccountStorage +import me.rhunk.snapenhance.core.util.ktx.toParcelFileDescriptor + +class RemoteAccountStorage( + private val context: RemoteSideContext +): AccountStorage.Stub() { + private val accountFolder = context.androidContext.filesDir.resolve("accounts").also { + if (!it.exists()) it.mkdirs() + } + + override fun getAccounts(): Map<String, String> { + return accountFolder.listFiles()?.sortedByDescending { it.lastModified() }?.mapNotNull { file -> + if (!file.name.endsWith(".zip") || !file.name.contains("|")) return@mapNotNull null + file.nameWithoutExtension.split('|').let { it[0] to it[1] } + }?.toMap() ?: emptyMap() + } + + override fun addAccount(userId: String, username: String, pfd: ParcelFileDescriptor) { + removeAccount(userId) + accountFolder.resolve("$userId|$username.zip").outputStream().use { fileOutputStream -> + ParcelFileDescriptor.AutoCloseInputStream(pfd).use { + it.copyTo(fileOutputStream) + } + } + } + + override fun removeAccount(userId: String) { + accountFolder.listFiles()?.firstOrNull { + it.nameWithoutExtension.startsWith(userId) + }?.also { + context.log.verbose("Removing account file: ${it.name}") + it.delete() + } + } + + override fun isAccountExists(userId: String): Boolean { + return accountFolder.listFiles()?.any { + it.nameWithoutExtension.startsWith(userId) + } ?: false + } + + override fun getAccountData(userId: String): ParcelFileDescriptor? { + return accountFolder.listFiles()?.firstOrNull { + it.nameWithoutExtension.startsWith(userId) + }?.inputStream()?.toParcelFileDescriptor(context.coroutineScope) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -71,6 +71,7 @@ class RemoteSideContext( val e2eeImplementation = E2EEImplementation(this) val messageLogger by lazy { LoggerWrapper(androidContext.getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)) } val tracker = RemoteTracker(this) + val accountStorage = RemoteAccountStorage(this) //used to load bitmoji selfies and download previews val imageLoader by lazy { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -178,6 +178,7 @@ class BridgeService : Service() { override fun getE2eeInterface() = remoteSideContext.e2eeImplementation override fun getLogger() = remoteSideContext.messageLogger override fun getTracker() = remoteSideContext.tracker + override fun getAccountStorage() = remoteSideContext.accountStorage override fun registerMessagingBridge(bridge: MessagingBridge) { messagingBridge = bridge } diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/AccountStorage.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/AccountStorage.aidl @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.bridge; + + +interface AccountStorage { + Map<String, String> getAccounts(); // userId -> username + void addAccount(String userId, String username, in ParcelFileDescriptor data); + void removeAccount(String userId); + boolean isAccountExists(String userId); + @nullable ParcelFileDescriptor getAccountData(String userId); +}+ \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -9,6 +9,7 @@ import me.rhunk.snapenhance.bridge.logger.LoggerInterface; import me.rhunk.snapenhance.bridge.logger.TrackerInterface; import me.rhunk.snapenhance.bridge.ConfigStateListener; import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge; +import me.rhunk.snapenhance.bridge.AccountStorage; interface BridgeInterface { /** @@ -84,6 +85,8 @@ interface BridgeInterface { TrackerInterface getTracker(); + AccountStorage getAccountStorage(); + oneway void registerMessagingBridge(MessagingBridge bridge); oneway void openSettingsOverlay(); diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -690,6 +690,16 @@ "name": "Call Recorder", "description": "Automatically records audio calls" }, + "account_switcher": { + "name": "Account Switcher", + "description": "Allows you to switch between accounts without logging out\nLong press on the search icon next to your Bitmoji profile to open the menu\nNote: this feature is experimental and will likely change in the future", + "properties": { + "auto_backup_current_account": { + "name": "Auto Backup Current Account", + "description": "Automatically backs up the current account when logging out or switching accounts" + } + } + }, "app_passcode": { "name": "App Passcode", "description": "Sets a passcode to lock the app" 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 @@ -18,6 +18,10 @@ class Experimental : ConfigContainer() { val forceMessageEncryption = boolean("force_message_encryption") } + class AccountSwitcherConfig : ConfigContainer(hasGlobalState = true) { + val autoBackupCurrentAccount = boolean("auto_backup_current_account", 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() } @@ -25,6 +29,7 @@ class Experimental : ConfigContainer() { val newChatActionMenu = boolean("new_chat_action_menu") { requireRestart() } val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } val callRecorder = boolean("call_recorder") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } + val accountSwitcher = container("account_switcher", AccountSwitcherConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val appPasscode = string("app_passcode") val appLockOnResume = boolean("app_lock_on_resume") val infiniteStoryBoost = boolean("infinite_story_boost") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -11,6 +11,7 @@ import android.os.Handler import android.os.HandlerThread import android.os.IBinder import de.robv.android.xposed.XposedHelpers +import me.rhunk.snapenhance.bridge.AccountStorage import me.rhunk.snapenhance.bridge.BridgeInterface import me.rhunk.snapenhance.bridge.ConfigStateListener import me.rhunk.snapenhance.bridge.DownloadCallback @@ -200,6 +201,8 @@ class BridgeClient( fun getTracker(): TrackerInterface = safeServiceCall { service.tracker } + fun getAccountStorage(): AccountStorage = safeServiceCall { service.accountStorage } + fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { service.registerMessagingBridge(bridge) } fun openSettingsOverlay() = safeServiceCall { service.openSettingsOverlay() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AccountSwitcher.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AccountSwitcher.kt @@ -0,0 +1,527 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.widget.Button +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.ui.AppMaterialTheme +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.util.ktx.toParcelFileDescriptor +import me.rhunk.snapenhance.core.util.ktx.vibrateLongPress +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import kotlin.random.Random + +class AccountSwitcher: Feature("Account Switcher", loadParams = FeatureLoadParams.INIT_SYNC or FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private var activity: Activity? = null + private var exportCallback: Pair<Int, String>? = null // requestCode -> userId + private var importRequestCode: Int? = null + + private val users = mutableStateListOf<Pair<String, String>>() + private val isLoggingActivity get() = activity?.javaClass?.name?.endsWith("LoginSignupActivity") == true + + private fun updateUsers() { + users.clear() + runCatching { + users.addAll(context.bridgeClient.getAccountStorage().accounts.map { it.key to it.value }) + }.onFailure { + context.log.error("Failed to update users", it) + } + } + + @Composable + private fun ManagementPopup() { + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + updateUsers() + } + } + + + Column( + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text("Account Switcher", modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), textAlign = TextAlign.Center, fontSize = 25.sp) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + item { + if (users.isEmpty()) { + Text("No accounts found! To start, backup your current account.", modifier = Modifier + .padding(16.dp) + .padding(16.dp) + .fillMaxWidth(), textAlign = TextAlign.Center) + } + } + + items(users) { user -> + var removeAccountPopup by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + colors = CardDefaults.cardColors( + containerColor = if (!isLoggingActivity && context.database.myUserId == user.first) MaterialTheme.colorScheme.surfaceBright + else MaterialTheme.colorScheme.surfaceDim + ) , + onClick = { + runCatching { + if (!isLoggingActivity && context.database.myUserId == user.first) { + context.shortToast("Already logged in as ${user.second}") + return@runCatching + } + + if (!isLoggingActivity && context.config.experimental.accountSwitcher.autoBackupCurrentAccount.get()) { + backupCurrentAccount() + } + + login(userId = user.first, username = user.second) + }.onFailure { + context.shortToast("Failed to login. Check logs for more info.") + context.log.error("Failed to login", it) + } + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(user.second, modifier = Modifier + .padding(10.dp) + .weight(1f)) + Row( + modifier = Modifier + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + FilledIconButton(onClick = { + val requestCode = Random.nextInt(100, 65535) + exportCallback = requestCode to user.first + + activity?.startActivityForResult( + Intent.createChooser( + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/zip" + putExtra(Intent.EXTRA_TITLE, "account_${user.second}.zip") + }, + "Export account" + ), + requestCode + ) + }) { + Icon(Icons.Default.SaveAlt, contentDescription = "Export account") + } + FilledIconButton(onClick = { + removeAccountPopup = true + }) { + Icon(Icons.Default.Delete, contentDescription = "Remove account") + } + } + } + } + + if (removeAccountPopup) { + AlertDialog( + onDismissRequest = { removeAccountPopup = false }, + confirmButton = { + Button(onClick = { + context.bridgeClient.getAccountStorage().removeAccount(user.first) + removeAccountPopup = false + updateUsers() + }) { + Text("Remove") + } + }, + title = { Text("Remove account") }, + text = { Text("Are you sure you want to remove ${user.second}?") }, + dismissButton = { + Button(onClick = { + removeAccountPopup = false + }) { + Text("Cancel") + } + }, + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + activity?.startActivityForResult( + Intent.createChooser( + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/zip" + }, + "Import account" + ), + Random.nextInt(100, 65535).also { + importRequestCode = it + } + ) + } + ) { + Text("Import account") + } + + if (!isLoggingActivity) { + Button( + modifier = Modifier + .fillMaxWidth(), + onClick = { + backupCurrentAccount() + updateUsers() + } + ) { + Text("Backup current account") + } + Button( + modifier = Modifier + .fillMaxWidth(), + onClick = { + if (context.config.experimental.accountSwitcher.autoBackupCurrentAccount.get()) { + backupCurrentAccount() + } + logout() + } + ) { + Text("Logout") + } + } + } + } + } + + + private fun showManagementPopup() { + context.runOnUiThread { + createComposeAlertDialog(activity!!) { + AppMaterialTheme(isDarkTheme = true) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface + ) { + ManagementPopup() + } + } + }.show() + } + } + + private fun logout() { + context.androidContext.dataDir.resolve( "shared_prefs/user_session_shared_pref.xml").takeIf { it.exists() }?.delete() + context.shortToast("Logged out") + context.softRestartApp() + } + + private fun login(userId: String, username: String) { + val accountData = context.bridgeClient.getAccountStorage().getAccountData(userId)?.let { pfd -> + ParcelFileDescriptor.AutoCloseInputStream(pfd).use { it.readBytes() } + } + if (accountData == null) { + context.shortToast("Account data not found") + return + } + + arrayOf( + context.androidContext.filesDir, + context.androidContext.cacheDir, + context.androidContext.dataDir.resolve("databases"), + ).forEach { dir -> dir.listFiles()?.forEach { it.deleteRecursively() } } + + val zipInputStream = ZipInputStream(accountData.inputStream()) + var entry: ZipEntry? + while (zipInputStream.nextEntry.also { entry = it } != null) { + val file = context.androidContext.dataDir.resolve(entry!!.name) + if (file.exists()) { + file.delete() + } else { + file.parentFile?.mkdirs() + } + context.log.debug("Extracting ${file.absolutePath}") + file.outputStream().use { + zipInputStream.copyTo(it) + } + } + + context.log.debug("Account data restored") + context.shortToast("Logged in as $username") + context.softRestartApp() + } + + private fun getCurrentAccountData(): ParcelFileDescriptor { + val pfd = ParcelFileDescriptor.createPipe() + + context.coroutineScope.launch(Dispatchers.IO) { + val zipOutputStream = ZipOutputStream(ParcelFileDescriptor.AutoCloseOutputStream(pfd[1])) + + for (file in arrayOf( + "databases/main.db", + "databases/main.db-shm", + "databases/main.db-wal", + "databases/core.db", + "databases/core.db-wal", + "databases/core.db-shm", + "shared_prefs/user_session_shared_pref.xml", + "shared_prefs/user_device_identity_keys.xml", + "shared_prefs/com.google.android.gms.appid.xml", + )) { + context.androidContext.dataDir.resolve(file).takeIf { it.exists() }?.inputStream()?.use { + context.log.verbose("Adding $file to zip") + zipOutputStream.putNextEntry(ZipEntry(file)) + it.copyTo(zipOutputStream) + zipOutputStream.closeEntry() + } + } + zipOutputStream.flush() + zipOutputStream.close() + } + + return pfd[0] + } + + private fun backupCurrentAccount() { + runCatching { + context.bridgeClient.getAccountStorage().addAccount( + context.database.myUserId, + context.database.getFriendInfo(context.database.myUserId)?.mutableUsername ?: "Unknown username", + getCurrentAccountData() + ) + context.shortToast("Account backed up!") + }.onFailure { + context.shortToast("Failed to backup account. Check logs for more info.") + context.log.error("Failed to backup account", it) + } + } + + private fun importAccount(fileUri: Uri) { + var tempZip: File? = null + var mainDbFile: File? = null + var mainDbWalFile: File? = null + var mainDbShmFile: File? = null + + runCatching { + // copy zip file + activity!!.contentResolver.openInputStream(fileUri)?.use { input -> + val bufferedInputStream = input.buffered() + val fileType = MediaDownloaderHelper.getFileType(bufferedInputStream) + + if (fileType != FileType.ZIP) { + throw Exception("Invalid file type") + } + + context.androidContext.cacheDir.resolve(System.currentTimeMillis().toString()).also { + tempZip = it + }.outputStream().use { output -> + bufferedInputStream.copyTo(output) + } + } + + context.log.verbose("Extracting account data") + + // extract main.db in cache + tempZip?.inputStream().use { fileInputStream -> + val zipInputStream = ZipInputStream(fileInputStream) + var entry: ZipEntry? + while (zipInputStream.nextEntry.also { entry = it } != null) { + val fileName = entry?.name?.substringAfterLast('/') ?: continue + if (!fileName.startsWith("main.db")) continue + + val file = context.androidContext.cacheDir.resolve(fileName) + context.log.verbose("Found ${entry!!.name} in zip file") + + when (fileName) { + "main.db" -> mainDbFile = file + "main.db-wal" -> mainDbWalFile = file + "main.db-shm" -> mainDbShmFile = file + } + + file.outputStream().use { + zipInputStream.copyTo(it) + } + } + } + + assert(mainDbFile != null) { "main.db not found in zip file" } + + SQLiteDatabase.openDatabase(mainDbFile!!.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { sqliteDatabase -> + val userId = sqliteDatabase.rawQuery("SELECT userId FROM SnapToken", null).use { + if (!it.moveToFirst()) throw Exception("userId not found in main.db") + it.getString(0) + } + context.log.verbose("Found userId $userId") + val username = sqliteDatabase.rawQuery("SELECT username FROM Friend WHERE userId = ?", arrayOf(userId)).use { + if (!it.moveToFirst()) throw Exception("username not found in main.db") + it.getString(0) + } + context.log.verbose("Found username $username") + tempZip?.inputStream()?.use { + context.bridgeClient.getAccountStorage().addAccount( + userId, + username, + it.toParcelFileDescriptor(context.coroutineScope) + ) + } + context.shortToast("Imported $username!") + updateUsers() + } + }.onFailure { + context.shortToast("Failed to import account: ${it.message}") + context.log.error("Failed to import account", it) + } + + tempZip?.delete() + mainDbFile?.delete() + mainDbWalFile?.delete() + mainDbShmFile?.delete() + } + + private fun hookActivityResult(activityClass: Class<*>) { + activityClass.hook("onActivityResult", HookStage.BEFORE) { param -> + val requestCode = param.arg<Int>(0) + val resultCode = param.arg<Int>(1) + val intent = param.arg<Intent>(2) + + if (importRequestCode == requestCode) { + importRequestCode = null + if (resultCode != Activity.RESULT_OK) return@hook + val uri = intent.data ?: return@hook + + context.coroutineScope.launch { importAccount(uri) } + } + + if (exportCallback?.first == requestCode) { + val userId = exportCallback?.second + exportCallback = null + param.setResult(null) + if (resultCode != Activity.RESULT_OK) return@hook + + context.coroutineScope.launch { + runCatching { + intent.data?.let { uri -> + val accountDataPfd = context.bridgeClient.getAccountStorage().getAccountData(userId) ?: throw Exception("Account data not found") + context.androidContext.contentResolver.openOutputStream(uri)?.use { outputStream -> + ParcelFileDescriptor.AutoCloseInputStream(accountDataPfd).use { + it.copyTo(outputStream) + } + } + context.shortToast("Account exported!") + } + }.onFailure { + context.shortToast("Failed to export account. Check logs for more info.") + context.log.error("Failed to export account", it) + } + } + } + } + } + + override fun onActivityCreate() { + if (context.config.experimental.accountSwitcher.globalState != true) return + + activity = context.mainActivity!! + hookActivityResult(activity!!::class.java) + val hovaHeaderSearchIcon = activity!!.resources.getId("hova_header_search_icon") + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.view.id != hovaHeaderSearchIcon) return@subscribe + + event.view.setOnLongClickListener { + activity!!.vibrateLongPress() + showManagementPopup() + false + } + } + } + + @SuppressLint("SetTextI18n") + override fun init() { + if (context.config.experimental.accountSwitcher.globalState != true) return + + findClass("com.snap.identity.service.ForcedLogoutBroadcastReceiver").hook("onReceive", HookStage.BEFORE) { param -> + val intent = param.arg<Intent>(1) + if (isLoggingActivity) return@hook + if (intent.getBooleanExtra("forced", false) && !context.config.experimental.preventForcedLogout.get()) { + runCatching { + val accountStorage = context.bridgeClient.getAccountStorage() + + if (accountStorage.isAccountExists(context.database.myUserId)) { + accountStorage.removeAccount(context.database.myUserId) + context.shortToast("Removed account due to forced logout") + } + } + return@hook + } + + if (context.config.experimental.accountSwitcher.autoBackupCurrentAccount.get()) { + backupCurrentAccount() + } + } + + findClass("com.snap.identity.loginsignup.ui.LoginSignupActivity").apply { + hookActivityResult(this) + hook("onCreate", HookStage.AFTER) { param -> + activity = param.thisObject() + activity!!.findViewById<FrameLayout>(android.R.id.content).addView(Button(activity).apply { + text = "Switch Account" + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply { + gravity = android.view.Gravity.TOP or android.view.Gravity.START + setMargins(32, 32, 0, 0) + } + setOnClickListener { + showManagementPopup() + } + }) + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -122,6 +122,7 @@ class FeatureManager( DefaultVolumeControls(), CallRecorder(), DisableMemoriesSnapFeed(), + AccountSwitcher(), ) initializeFeatures() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt @@ -6,10 +6,15 @@ import android.content.res.Resources import android.content.res.Resources.Theme import android.content.res.TypedArray import android.graphics.drawable.Drawable +import android.os.ParcelFileDescriptor import android.os.VibrationEffect import android.os.Vibrator import androidx.core.graphics.ColorUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import me.rhunk.snapenhance.common.Constants +import java.io.InputStream @SuppressLint("DiscouragedApi") @@ -52,4 +57,21 @@ fun Context.isDarkTheme(): Boolean { ).getColor(0, 0).let { ColorUtils.calculateLuminance(it) > 0.5 } -}- \ No newline at end of file +} + +fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFileDescriptor { + val pfd = ParcelFileDescriptor.createPipe() + val fos = ParcelFileDescriptor.AutoCloseOutputStream(pfd[1]) + + coroutineScope.launch(Dispatchers.IO) { + try { + copyTo(fos) + } finally { + close() + fos.flush() + fos.close() + } + } + + return pfd[0] +}