commit f53e2db68d48feb5506d7fc7ef251b402ae24033
parent 31c6bef10f85275aa44e6fd66fa4178c8a9451b0
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sun, 17 Mar 2024 23:43:25 +0100

feat(actions): manage friend list

Diffstat:
Mcommon/src/main/assets/lang/en_US.json | 4++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/action/ActionManager.kt | 2++
Acore/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt | 23++++++-----------------
5 files changed, 287 insertions(+), 17 deletions(-)

diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -134,6 +134,10 @@ "name": "Clean Snapchat Cache", "description": "Cleans the Snapchat Cache" }, + "manage_friend_list": { + "name": "Manage Friend List", + "description": "Import/export your friends list when backing up" + }, "export_chat_messages": { "name": "Export Chat Messages", "description": "Exports conversation messages into a JSON/HTML/TXT file" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt @@ -9,6 +9,7 @@ enum class EnumAction( EXPORT_CHAT_MESSAGES("export_chat_messages"), EXPORT_MEMORIES("export_memories"), BULK_MESSAGING_ACTION("bulk_messaging_action"), + MANAGE_FRIEND_LIST("manage_friend_list"), CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true); companion object { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/ActionManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/ActionManager.kt @@ -7,6 +7,7 @@ import me.rhunk.snapenhance.core.action.impl.BulkMessagingAction import me.rhunk.snapenhance.core.action.impl.CleanCache import me.rhunk.snapenhance.core.action.impl.ExportChatMessages import me.rhunk.snapenhance.core.action.impl.ExportMemories +import me.rhunk.snapenhance.core.action.impl.ManageFriendList class ActionManager( private val modContext: ModContext, @@ -17,6 +18,7 @@ class ActionManager( EnumAction.CLEAN_CACHE to CleanCache(), EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages(), EnumAction.BULK_MESSAGING_ACTION to BulkMessagingAction(), + EnumAction.MANAGE_FRIEND_LIST to ManageFriendList(), EnumAction.EXPORT_MEMORIES to ExportMemories(), ).map { it.key to it.value.apply { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt @@ -0,0 +1,273 @@ +package me.rhunk.snapenhance.core.action.impl + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +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.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.core.action.AbstractAction +import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent +import me.rhunk.snapenhance.core.features.impl.experiments.AddFriendSourceSpoof +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.wrapper.impl.Snapchatter +import me.rhunk.snapenhance.mapper.impl.FriendRelationshipChangerMapper +import kotlin.random.Random + +class ManageFriendList : AbstractAction() { + private var pendingPickerAction: Pair<Int, (data: Uri) -> Unit>? = null + + private val uuidRegex by lazy { + Regex("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}") + } + + private fun addFriend(userId: String) { + val friendRelationshipChangerInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance + context.mappings.useMapper(FriendRelationshipChangerMapper::class) { + val addFriend = friendshipRelationshipChangerKtx.get()?.methods?.firstOrNull { it.name == addFriendMethod.get() } + ?: return@useMapper + + addFriend.invoke( + null, + friendRelationshipChangerInstance, + userId, + addFriend.parameterTypes[2].enumConstants.first { it.toString() == "ADDED_BY_USERNAME" }, + addFriend.parameterTypes[3].enumConstants.first { it.toString() == "SEARCH" }, + addFriend.parameterTypes[4].enumConstants.first { it.toString() == "SEARCH" }, + 0 + ) + } + } + + override fun onActivityCreate() { + context.runOnUiThread { + context.actionManager.execute(EnumAction.MANAGE_FRIEND_LIST) + } + + context.event.subscribe(ActivityResultEvent::class) { event -> + if (event.requestCode == pendingPickerAction?.first) { + val pendingAction = pendingPickerAction ?: return@subscribe + this.pendingPickerAction = null + event.canceled = true + pendingAction.second(event.intent.data!!) + } + } + } + + private fun exportFriends( + userIds: List<String> + ) { + pendingPickerAction = Random.nextInt(0, 65535) to { data -> + context.androidContext.contentResolver.openOutputStream(data).use { output -> + output?.bufferedWriter()?.use { writer -> + userIds.forEach { + writer.write(it) + writer.newLine() + } + } + context.longToast("Exported ${userIds.size} friends!") + } + } + context.mainActivity?.startActivityForResult( + Intent.createChooser( + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TITLE, "my_friends.txt") + }, + "Select a location to save the file" + ), + pendingPickerAction!!.first + ) + } + + private val userIdToSnapchatter = mutableMapOf<String, Snapchatter>() + + @Composable + private fun ManagerDialog() { + val pendingFriendRequests = remember { mutableStateMapOf<String, Job>() } + var fetchedFriends by remember { mutableStateOf<List<String>?>(null) } // list of uuids + val coroutineScope = rememberCoroutineScope() + + if (fetchedFriends == null) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 200.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Manage Friend List", fontSize = 20.sp) + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = "Export friends allows you to save a list of your friends' IDs in a text file. Importing from a file will display the friends in a list where you can add them.", + fontSize = 14.sp, + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button(onClick = { + exportFriends(context.database.getAllFriends().filter { it.friendLinkType == FriendLinkType.MUTUAL.value && it.addedTimestamp > 0L }.mapNotNull { it.userId }) + }) { + Text("Export friends") + } + Button(onClick = { + pendingPickerAction = Random.nextInt(0, 65535) to { data -> + runCatching { + fetchedFriends = null + context.androidContext.contentResolver.openInputStream(data).use { input -> + fetchedFriends = input?.bufferedReader()?.readLines()?.filter { + it.matches(uuidRegex) + }?.map { it.trim() }?.toMutableList() ?: mutableListOf() + } + }.onFailure { + context.log.error("Failed to import friends", it) + context.longToast("Failed to import friends: ${it.message}") + } + } + // launch file picker + context.mainActivity?.startActivityForResult( + Intent.createChooser( + Intent(Intent.ACTION_GET_CONTENT).apply { type = "*/*" }, + "Select a file" + ), + pendingPickerAction!!.first + ) + }) { + Text("Import from file") + } + } + } + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton( + modifier = Modifier.padding(8.dp), + onClick = { + fetchedFriends = null + } + ) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + } + LazyColumn( + modifier = Modifier.weight(1f).padding(8.dp) + ) { + item { + if (fetchedFriends?.isEmpty() == true) { + Text("No friends found", modifier = Modifier.padding(8.dp)) + } + } + items(fetchedFriends ?: emptyList()) { userId -> + fun fetchLocalLinkType(): FriendLinkType? { + return context.database.getFriendInfo(userId)?.friendLinkType?.let { FriendLinkType.fromValue(it) } + } + + var friendSnapchatter by remember(userId) { mutableStateOf<Snapchatter?>(null) } + var failedToFetch by remember(userId) { mutableStateOf(false) } + var friendLinkType by remember(userId) { mutableStateOf(fetchLocalLinkType()) } + + LaunchedEffect(userId) { + launch(Dispatchers.IO) { + friendSnapchatter = userIdToSnapchatter.getOrPut(userId) { + context.feature(Messaging::class).fetchSnapchatterInfos(listOf(userId)).firstOrNull() ?: run { + failedToFetch = true + return@launch + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ){ + Column( + modifier = Modifier.weight(1f) + ) { + friendSnapchatter?.let { snapchatter -> + Text(snapchatter.displayName?.let { "$it (${snapchatter.username}) " } ?: snapchatter.username ?: "Unknown") + } + Text(userId, fontSize = 12.sp, fontWeight = FontWeight.Light) + } + + if (friendSnapchatter != null && friendLinkType != FriendLinkType.FOLLOWING) { + Button( + enabled = friendLinkType != FriendLinkType.MUTUAL, + onClick = { + val prevLinkType = fetchLocalLinkType() + if (prevLinkType == FriendLinkType.MUTUAL || pendingFriendRequests[userId]?.isActive == true) return@Button + addFriend(userId) + pendingFriendRequests[userId] = coroutineScope.launch { + withTimeout(10000) { + while (fetchLocalLinkType()?.value == prevLinkType?.value) { + delay(500) + } + } + }.apply { + invokeOnCompletion { + pendingFriendRequests.remove(userId) + friendLinkType = fetchLocalLinkType() + } + } + } + ) { + if (friendLinkType == FriendLinkType.MUTUAL) { + Text("Added") + } else if (pendingFriendRequests[userId]?.isActive == true) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(20.dp), strokeWidth = 1.dp) + } else { + Text("Add") + } + } + } + } + } + } + } + } + } + + override fun run() { + context.coroutineScope.launch(Dispatchers.Main) { + createComposeAlertDialog(context.mainActivity!!) { + ManagerDialog() + }.apply { + setCanceledOnTouchOutside(false) + show() + } + } + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt @@ -18,18 +18,7 @@ class FriendRelationshipChangerMapper : AbstractClassMapper("FriendRelationshipC mapper { for (classDef in classes) { classDef.methods.firstOrNull { it.name == "<init>" }?.implementation?.findConstString("FriendRelationshipChangerImpl")?.takeIf { it } ?: continue - val addFriendDexMethod = classDef.methods.first { - it.parameterTypes.size > 4 && - getClass(it.parameterTypes[1])?.isEnum() == true && - getClass(it.parameterTypes[2])?.isEnum() == true && - getClass(it.parameterTypes[3])?.isEnum() == true && - it.parameters[4].type == "Ljava/lang/String;" - } - - this@FriendRelationshipChangerMapper.apply { - classReference.set(classDef.getClassName()) - } - + classReference.set(classDef.getClassName()) return@mapper } } @@ -49,11 +38,11 @@ class FriendRelationshipChangerMapper : AbstractClassMapper("FriendRelationshipC val addFriendDexMethod = classDef.methods.firstOrNull { Modifier.isStatic(it.accessFlags) && - it.parameterTypes.size == 5 && - it.parameterTypes[1] == "Ljava/lang/String;" && - getClass(it.parameterTypes[2])?.isEnum() == true && - getClass(it.parameterTypes[4])?.isEnum() == true && - it.parameterTypes[5] == "I" + it.parameterTypes.size == 6 && + it.parameterTypes[1] == "Ljava/lang/String;" && + getClass(it.parameterTypes[2])?.isEnum() == true && + getClass(it.parameterTypes[4])?.isEnum() == true && + it.parameterTypes[5] == "I" } ?: return@mapper addFriendMethod.set(addFriendDexMethod.name)