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:
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)