From ff398ccc9a56c65f09b9c8c76fc1d2dc6a95beef Mon Sep 17 00:00:00 2001 From: RSR/ <52901789+RSR1337@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:52:32 +0400 Subject: [PATCH] Friend request --- common/src/main/assets/lang/ar_AE.json | 9 +- common/src/main/assets/lang/en_US.json | 9 +- .../core/action/impl/BulkMessagingAction.kt | 106 +++++++++++++++++- .../core/database/DatabaseAccess.kt | 33 ++++++ 4 files changed, 150 insertions(+), 7 deletions(-) diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index 3f1c4d8b..4d35b70c 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -2832,8 +2832,13 @@ "clear_conversations": "مسح المحادثات", "clear_friend_feed": "مسح موجز الأصدقاء ({count})", "unfollow": "إلغاء المتابعة", - "remove": "إزالة" + "remove": "إزالة", + "accept": "تم القبول", + "ignore": "تم التجاهل" }, + "accept_requests": "قبول الطلبات", + "ignore_requests": "تجاهل الطلبات", + "cleared_from_feed": "تم المسح من الخلاصة", "leave_groups": "مغادرة {count} مجموعات", "left_group_success": "تمت مغادرة المجموعة بنجاح", "failed_to_leave_group": "فشل مغادرة المجموعة: {error}", @@ -2872,6 +2877,8 @@ "non_streaks": "بدون سلاسل", "followed": "المتابَعين", "following": "تتابعهم", + "incoming": "طلبات صداقة", + "incoming_follower": "طلبات متابعة", "location_on_map": "الموقع على الخريطة" }, "sort_options": { diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 4d911c3e..6c657a9f 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -2831,8 +2831,13 @@ "clear_conversations": "Clear Conversations", "clear_friend_feed": "Clear Friend Feed ({count})", "unfollow": "Unfollow", - "remove": "Remove" + "remove": "Remove", + "accept": "Accepted", + "ignore": "Ignored" }, + "accept_requests": "Accept requests", + "ignore_requests": "Ignore requests", + "cleared_from_feed": "Cleared from feed", "leave_groups": "Leave {count} groups", "left_group_success": "Left group successfully", "failed_to_leave_group": "Failed to leave group: {error}", @@ -2871,6 +2876,8 @@ "non_streaks": "Non Streaks", "followed": "Followed", "following": "Following", + "incoming": "Friend requests", + "incoming_follower": "Follower requests", "location_on_map": "Location on Map" }, "sort_options": { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/action/impl/BulkMessagingAction.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/action/impl/BulkMessagingAction.kt index 2008a2d3..5e2dc74e 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/action/impl/BulkMessagingAction.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/action/impl/BulkMessagingAction.kt @@ -95,6 +95,8 @@ class BulkMessagingAction : AbstractAction() { STREAKS, NON_STREAKS, FOLLOWING, + INCOMING, + INCOMING_FOLLOWER, LOCATION_ON_MAP } @@ -300,6 +302,8 @@ class BulkMessagingAction : AbstractAction() { } isFollowing } + Filter.INCOMING -> friend.friendLinkType == FriendLinkType.INCOMING.value + Filter.INCOMING_FOLLOWER -> friend.friendLinkType == FriendLinkType.INCOMING_FOLLOWER.value Filter.LOCATION_ON_MAP -> betterLocation.locationHistory.contains(friend.userId) } && nameFilter.takeIf { it.isNotBlank() }?.let { name -> friend.mutableUsername?.contains( @@ -346,11 +350,31 @@ class BulkMessagingAction : AbstractAction() { val myLocation = betterLocation.locationHistory[context.database.myUserId] withContext(Dispatchers.IO) { - // Load friends if needed + val friendIdsStillInFeed = runCatching { + context.database.getFeedEntries(Int.MAX_VALUE) + .filter { it.conversationType == 0 && it.participantsSize == 2 } + .mapNotNull { it.participants?.firstOrNull { id -> id != context.database.myUserId } } + .toSet() + }.getOrElse { emptySet() } + + val incomingRequestUserIds = if (filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER) { + runCatching { context.database.getIncomingRequestUserIds() }.getOrElse { emptySet() } + } else emptySet() + val newFriends = if (conversationType == ConversationType.FRIENDS_ONLY || conversationType == ConversationType.BOTH) { context.database.getAllFriends().let { friends -> filterFriends(friends, filter, nameFilter) - }.filter { it.userId?.let { id -> !hiddenFriendIds.contains(id) } == true }.toMutableList() + } + .filter { it.userId?.let { id -> !hiddenFriendIds.contains(id) } == true } + .filter { friend -> + when { + filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER -> + friend.userId != null && friend.userId in incomingRequestUserIds + else -> + friendIdsStillInFeed.isEmpty() || friend.userId in friendIdsStillInFeed + } + } + .toMutableList() } else mutableListOf() val newGroups = if (conversationType == ConversationType.GROUPS_ONLY || conversationType == ConversationType.BOTH) { @@ -1053,10 +1077,31 @@ class BulkMessagingAction : AbstractAction() { } } } + val incomingRequests = filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER when (conversationType) { - ConversationType.FRIENDS_ONLY -> if (following) { - addUnfollowActions("failed_to_fetch_conversations") - } else { + ConversationType.FRIENDS_ONLY -> when { + following -> addUnfollowActions("failed_to_fetch_conversations") + incomingRequests -> { + put({ "${translation["accept_requests"]} (${selectedFriends.size})" }) { + val ids = selectedFriends.toList() + selectedFriends.clear() + removeAction(ctx, ids, 500L to 1200L) { userId, setDialogMessage -> + acceptFriendRequest(userId) + setDialogMessage(translation["actions.accept"]) + markRemoval(userId, null) + }.invokeOnCompletion { coroutineScope.launch { refreshList() } } + } + put({ "${translation["ignore_requests"]} (${selectedFriends.size})" }) { + val ids = selectedFriends.toList() + selectedFriends.clear() + removeAction(ctx, ids, 500L to 1200L) { userId, setDialogMessage -> + ignoreFriendRequest(userId) + setDialogMessage(translation["actions.ignore"]) + markRemoval(userId, null) + }.invokeOnCompletion { coroutineScope.launch { refreshList() } } + } + } + else -> { put({ translation.format("clean_conversations", "count" to selectedFriends.size.toString()) }) { context.feature(Messaging::class).conversationManager?.getOneOnOneConversationIds(selectedFriends.toList().also { selectedFriends.clear() }, onError = { error -> context.shortToast(translation.format("failed_to_fetch_conversations", "error" to error)) @@ -1117,6 +1162,7 @@ class BulkMessagingAction : AbstractAction() { }.invokeOnCompletion { coroutineScope.launch { refreshList() } } }) } + } } ConversationType.GROUPS_ONLY -> if (!following) { put({ translation.format("clean_group_conversations", "count" to selectedGroups.size.toString()) }) { @@ -1409,6 +1455,56 @@ class BulkMessagingAction : AbstractAction() { } } + private fun acceptFriendRequest(userId: String) { + val friendRelationshipChangerInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance + ?: run { + context.log.error("Accept friend: FriendRelationshipChanger instance not available") + return + } + context.mappings.useMapper(FriendRelationshipChangerMapper::class) { + runCatching { + val f9lClass = helperClass.getAsClass() ?: return@runCatching context.log.error("Could not find FriendRelationshipChanger helper class") + val addFriendMethodName = addFriend14Method.get() ?: return@runCatching context.log.error("Could not find add friend method name") + val sourceTypeClass = sourceType.getAsClass() ?: return@runCatching context.log.error("Could not find source type class") + val pageTypeClass = pageType.getAsClass() ?: return@runCatching context.log.error("Could not find page type class") + val method = f9lClass.declaredMethods.firstOrNull { it.name == addFriendMethodName } + ?: return@runCatching context.log.error("Could not find $addFriendMethodName method") + fun findStaticField(clazz: Class<*>): Any? = clazz.declaredFields.firstOrNull { field -> + java.lang.reflect.Modifier.isStatic(field.modifiers) && field.type == clazz + }?.let { field -> + runCatching { field.isAccessible = true; field.get(null)?.takeIf { it.javaClass == clazz } }.getOrNull() + } + val enumClass = method.parameterTypes[2] + val enumConstants = enumClass.enumConstants ?: enumClass.getMethod("values").invoke(null) as? Array<*> + ?: return@runCatching context.log.error("Could not get enum constants") + val addedByUsername = enumConstants.firstOrNull { it.toString().contains("USERNAME", ignoreCase = true) } + ?: return@runCatching context.log.error("Could not find ADDED_BY_USERNAME enum") + val sourceTypeDefault = findStaticField(sourceTypeClass) ?: return@runCatching context.log.error("Could not find source type static field") + val pageTypeDefault = findStaticField(pageTypeClass) ?: return@runCatching context.log.error("Could not find page type static field") + method.isAccessible = true + val paramCount = method.parameterTypes.size + val args = arrayOfNulls(paramCount).apply { + if (paramCount >= 1) set(0, friendRelationshipChangerInstance) + if (paramCount >= 2) set(1, userId) + if (paramCount >= 3) set(2, addedByUsername) + if (paramCount >= 4) set(3, sourceTypeDefault) + if (paramCount >= 5) set(4, pageTypeDefault) + for (i in 5 until paramCount - 1) set(i, null) + if (paramCount >= 14) set(13, 4064) + else if (paramCount >= 13) set(paramCount - 1, 4064) + } + val result = method.invoke(null, *args) + result?.javaClass?.methods?.firstOrNull { it.name == "subscribe" && it.parameterCount == 0 }?.let { it.isAccessible = true; it.invoke(result) } + }.onFailure { + context.log.error("Failed to accept friend request $userId", it) + } + } + } + + private fun ignoreFriendRequest(userId: String) { + context.database.setIncomingRequestIgnored(userId) + } + private suspend fun cleanConversation( conversationId: String, setDialogMessage: (String) -> Unit diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/database/DatabaseAccess.kt index b80a2cb7..f81413f1 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/database/DatabaseAccess.kt @@ -487,6 +487,39 @@ class DatabaseAccess( } } + /** User IDs in FriendWhoAddedMe with added=0 and ignored=0 (real pending requests as shown in Snapchat). */ + fun getIncomingRequestUserIds(): Set { + return useDatabase(DatabaseType.MAIN)?.performOperation { + safeRawQuery( + "SELECT userId FROM FriendWhoAddedMe WHERE userId IS NOT NULL AND COALESCE(added, 0) = 0 AND COALESCE(ignored, 0) = 0", + null + )?.use { query -> + val set = mutableSetOf() + while (query.moveToNext()) { + query.getStringOrNull("userId")?.let { set.add(it) } + } + set + } + } ?: emptySet() + } + + /** Mark an incoming request as ignored locally (FriendWhoAddedMe.ignored = 1). */ + fun setIncomingRequestIgnored(userId: String): Boolean { + var updated = false + useDatabase(DatabaseType.MAIN, writeMode = true)?.apply { + performOperation { + updated = update( + "FriendWhoAddedMe", + ContentValues().apply { put("ignored", 1) }, + "userId = ?", + arrayOf(userId) + ) > 0 + } + close() + } + return updated + } + fun setStoriesViewedState(userId: String, viewed: Boolean): Boolean { var success = false useDatabase(DatabaseType.MAIN, writeMode = true)?.apply {