feat(experimental): account switcher

This commit is contained in:
rhunk 2024-03-09 15:03:35 +01:00
parent b4f6e4f3bd
commit c77d11f4a0
11 changed files with 634 additions and 1 deletions

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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);
}

View File

@ -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();

View File

@ -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"

View File

@ -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")

View File

@ -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() }

View File

@ -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()
}
})
}
}
}
}

View File

@ -122,6 +122,7 @@ class FeatureManager(
DefaultVolumeControls(),
CallRecorder(),
DisableMemoriesSnapFeed(),
AccountSwitcher(),
)
initializeFeatures()

View File

@ -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
}
}
}
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]
}