feat(actions): manage friend list

This commit is contained in:
rhunk
2024-03-17 23:43:25 +01:00
parent 31c6bef10f
commit f53e2db68d
5 changed files with 286 additions and 17 deletions

View File

@ -134,6 +134,10 @@
"name": "Clean Snapchat Cache", "name": "Clean Snapchat Cache",
"description": "Cleans the 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": { "export_chat_messages": {
"name": "Export Chat Messages", "name": "Export Chat Messages",
"description": "Exports conversation messages into a JSON/HTML/TXT file" "description": "Exports conversation messages into a JSON/HTML/TXT file"

View File

@ -9,6 +9,7 @@ enum class EnumAction(
EXPORT_CHAT_MESSAGES("export_chat_messages"), EXPORT_CHAT_MESSAGES("export_chat_messages"),
EXPORT_MEMORIES("export_memories"), EXPORT_MEMORIES("export_memories"),
BULK_MESSAGING_ACTION("bulk_messaging_action"), BULK_MESSAGING_ACTION("bulk_messaging_action"),
MANAGE_FRIEND_LIST("manage_friend_list"),
CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true); CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true);
companion object { companion object {

View File

@ -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.CleanCache
import me.rhunk.snapenhance.core.action.impl.ExportChatMessages import me.rhunk.snapenhance.core.action.impl.ExportChatMessages
import me.rhunk.snapenhance.core.action.impl.ExportMemories import me.rhunk.snapenhance.core.action.impl.ExportMemories
import me.rhunk.snapenhance.core.action.impl.ManageFriendList
class ActionManager( class ActionManager(
private val modContext: ModContext, private val modContext: ModContext,
@ -17,6 +18,7 @@ class ActionManager(
EnumAction.CLEAN_CACHE to CleanCache(), EnumAction.CLEAN_CACHE to CleanCache(),
EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages(), EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages(),
EnumAction.BULK_MESSAGING_ACTION to BulkMessagingAction(), EnumAction.BULK_MESSAGING_ACTION to BulkMessagingAction(),
EnumAction.MANAGE_FRIEND_LIST to ManageFriendList(),
EnumAction.EXPORT_MEMORIES to ExportMemories(), EnumAction.EXPORT_MEMORIES to ExportMemories(),
).map { ).map {
it.key to it.value.apply { it.key to it.value.apply {

View File

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

View File

@ -18,18 +18,7 @@ class FriendRelationshipChangerMapper : AbstractClassMapper("FriendRelationshipC
mapper { mapper {
for (classDef in classes) { for (classDef in classes) {
classDef.methods.firstOrNull { it.name == "<init>" }?.implementation?.findConstString("FriendRelationshipChangerImpl")?.takeIf { it } ?: continue classDef.methods.firstOrNull { it.name == "<init>" }?.implementation?.findConstString("FriendRelationshipChangerImpl")?.takeIf { it } ?: continue
val addFriendDexMethod = classDef.methods.first { classReference.set(classDef.getClassName())
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())
}
return@mapper return@mapper
} }
} }
@ -49,11 +38,11 @@ class FriendRelationshipChangerMapper : AbstractClassMapper("FriendRelationshipC
val addFriendDexMethod = classDef.methods.firstOrNull { val addFriendDexMethod = classDef.methods.firstOrNull {
Modifier.isStatic(it.accessFlags) && Modifier.isStatic(it.accessFlags) &&
it.parameterTypes.size == 5 && it.parameterTypes.size == 6 &&
it.parameterTypes[1] == "Ljava/lang/String;" && it.parameterTypes[1] == "Ljava/lang/String;" &&
getClass(it.parameterTypes[2])?.isEnum() == true && getClass(it.parameterTypes[2])?.isEnum() == true &&
getClass(it.parameterTypes[4])?.isEnum() == true && getClass(it.parameterTypes[4])?.isEnum() == true &&
it.parameterTypes[5] == "I" it.parameterTypes[5] == "I"
} ?: return@mapper } ?: return@mapper
addFriendMethod.set(addFriendDexMethod.name) addFriendMethod.set(addFriendDexMethod.name)