diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt index 2c548c47..ce48199c 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -29,20 +29,9 @@ import kotlin.system.measureTimeMillis class BridgeService : Service() { private lateinit var remoteSideContext: RemoteSideContext private var syncCallback: SyncCallback? = null - private var syncCallbackBinder: IBinder? = null - private val syncCallbackDeathRecipient = IBinder.DeathRecipient { - remoteSideContext.takeIf { ::remoteSideContext.isInitialized }?.log?.warn("Sync callback binder died") - clearSyncCallback() - } var messagingBridge: MessagingBridge? = null private fun clearSyncCallback() { - syncCallbackBinder?.let { binder -> - runCatching { - binder.unlinkToDeath(syncCallbackDeathRecipient, 0) - } - } - syncCallbackBinder = null syncCallback = null } @@ -66,12 +55,6 @@ class BridgeService : Service() { fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) { val callback = syncCallback ?: return runCatching { - if (!callback.asBinder().pingBinder()) { - clearSyncCallback() - remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead") - return - } - val database = remoteSideContext.database val syncedObject = when (scope) { SocialScope.FRIEND -> { @@ -209,14 +192,6 @@ class BridgeService : Service() { override fun sync(callback: SyncCallback) { clearSyncCallback() syncCallback = callback - syncCallbackBinder = callback.asBinder().also { binder -> - runCatching { - binder.linkToDeath(syncCallbackDeathRecipient, 0) - }.onFailure { - clearSyncCallback() - throw it - } - } measureTimeMillis { remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId -> triggerScopeSync(SocialScope.FRIEND, friendId, true) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt index bbd43d25..e6b7b0b8 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt @@ -1,5 +1,6 @@ package me.eternal.purrfectsnap.download +import android.content.ContentUris import android.content.ContentValues import android.content.Intent import android.graphics.Bitmap @@ -61,6 +62,10 @@ class DownloadProcessor ( private val remoteSideContext: RemoteSideContext, private val callback: DownloadCallback ) { + private data class GallerySaveResult( + val uri: Uri, + val alreadyDownloaded: Boolean = false + ) private val translation by lazy { remoteSideContext.translation.getCategory("download_processor") @@ -118,78 +123,36 @@ class DownloadProcessor ( } } - val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension + val fileName = buildOutputFileName(metadata.outputPath, fileType) val configuredFolder = remoteSideContext.config.root.downloader.saveFolder.get().orEmpty().trim() - if (configuredFolder.isBlank()) { - val outputUri = saveToSystemDefault(fileName, fileType, inputFile, metadata) - ?: throw Exception("Failed to save media (no output uri)") - pendingTask.task.extra = outputUri.toString() - pendingTask.success() - callbackOnSuccess(fileName) - return - } + val saveResult = if (configuredFolder.isBlank()) { + saveToSystemDefault(fileName, fileType, inputFile, metadata) + } else { + runCatching { + saveToConfiguredFolder( + configuredFolder = configuredFolder, + fileName = fileName, + fileType = fileType, + inputFile = inputFile, + metadata = metadata, + pendingTask = pendingTask + ) + }.onFailure { + remoteSideContext.log.error("Failed to save to configured folder, falling back to system default", it) + }.getOrNull() ?: saveToSystemDefault(fileName, fileType, inputFile, metadata) + } ?: throw Exception("Failed to save media (no output uri)") - val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder)) - ?: throw Exception("Failed to open output folder") + pendingTask.task.extra = saveResult.uri.toString() + pendingTask.success() - val outputFileFolder = metadata.outputPath.let { - if (it.contains("/")) { - it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> - folder.findFile(name) ?: folder.createDirectory(name)!! - } - } else { - outputFolder - } - } - - // checks if the file already exists and if it does, compares its contents with the input file, if contents differ, deletes existing file. - outputFileFolder.findFile(fileName)?.let { existingFile -> - pendingTask.updateProgress("Comparing existing media") - if (existingFile.length() != inputFile.length()) { - existingFile.delete() - return@let - } - - remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)?.use { existingInputStream -> - val buffer1 = ByteArray(1024 * 1024) - val buffer2 = ByteArray(1024 * 1024) - var read1: Int - var read2: Int - - inputFile.inputStream().use { inputStream -> - while (true) { - read1 = inputStream.read(buffer1) - read2 = existingInputStream.read(buffer2) - if (read1 != read2 || !buffer1.contentEquals(buffer2)) { - existingFile.delete() - return@let - } - if (read1 == -1) break - } - } - } - - pendingTask.task.extra = existingFile.uri.toString() - pendingTask.success() + if (saveResult.alreadyDownloaded) { callbackOnFailure(translation["already_downloaded_toast"]) return } - val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!! - - pendingTask.updateProgress("Saving media to gallery") - remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!.use { outputStream -> - inputFile.inputStream().use { inputStream -> - inputStream.copyTo(outputStream) - } - } - - pendingTask.task.extra = outputFile.uri.toString() - pendingTask.success() - runCatching { remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { - data = outputFile.uri + data = saveResult.uri }) }.onFailure { remoteSideContext.log.error("Failed to scan media file", it) @@ -205,15 +168,184 @@ class DownloadProcessor ( } } + private fun saveToConfiguredFolder( + configuredFolder: String, + fileName: String, + fileType: FileType, + inputFile: File, + metadata: DownloadMetadata, + pendingTask: PendingTask, + ): GallerySaveResult { + val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder)) + ?: throw Exception("Failed to open output folder") + + val outputFileFolder = metadata.outputPath.let { + if (it.contains("/")) { + it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> + folder.findFile(name) + ?: folder.createDirectory(name) + ?: throw Exception("Failed to create output directory $name") + } + } else { + outputFolder + } + } + + outputFileFolder.findFile(fileName)?.let { existingFile -> + pendingTask.updateProgress("Comparing existing media") + if (existingFile.length() != inputFile.length()) { + existingFile.delete() + } else { + val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri) + ?: throw Exception("Failed to open existing media for comparison") + + existingInputStream.use { currentExistingInputStream -> + val buffer1 = ByteArray(1024 * 1024) + val buffer2 = ByteArray(1024 * 1024) + var read1: Int + var read2: Int + + inputFile.inputStream().use { inputStream -> + while (true) { + read1 = inputStream.read(buffer1) + read2 = currentExistingInputStream.read(buffer2) + if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) { + existingFile.delete() + return@let + } + if (read1 == -1) break + } + } + } + + return GallerySaveResult(existingFile.uri, alreadyDownloaded = true) + } + } + + val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName) + ?: throw Exception("Failed to create output file $fileName") + + pendingTask.updateProgress("Saving media to gallery") + val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri) + ?: throw Exception("Failed to open output stream for $fileName") + + outputStream.use { currentOutputStream -> + inputFile.inputStream().use { inputStream -> + inputStream.copyTo(currentOutputStream) + } + } + + return GallerySaveResult(outputFile.uri) + } + + private fun buffersMatch(left: ByteArray, right: ByteArray, length: Int): Boolean { + for (index in 0 until length) { + if (left[index] != right[index]) return false + } + return true + } + + private fun buildOutputFileName(outputPath: String, fileType: FileType): String { + val rawName = outputPath.substringAfterLast("/").ifBlank { "media" } + val sanitizedBase = sanitizeFileName(rawName.substringBeforeLast(".", rawName)) + val currentExtension = rawName.substringAfterLast(".", "").lowercase() + val targetExtension = fileType.fileExtension?.lowercase() ?: "dat" + val extension = if (currentExtension == targetExtension) { + currentExtension + } else { + targetExtension + } + return "$sanitizedBase.$extension" + } + + private fun sanitizeFileName(name: String): String { + return name + .replace(Regex("[\\\\/:*?\"<>|]"), "_") + .replace(Regex("\\p{Cntrl}"), "") + .replace(Regex("\\s+"), " ") + .trim() + .trim('.') + .ifBlank { "media" } + } + + private fun sanitizeRelativePath(path: String): String { + return path.split("/") + .mapNotNull { segment -> + segment.trim() + .takeIf { it.isNotBlank() } + ?.let(::sanitizeFileName) + } + .joinToString("/") + } + + private fun appendNameSuffix(fileName: String, index: Int): String { + val extension = fileName.substringAfterLast('.', "") + val baseName = fileName.substringBeforeLast(".", fileName) + return if (extension.isBlank()) { + "$baseName ($index)" + } else { + "$baseName ($index).$extension" + } + } + + private fun findExistingMediaUri(collection: Uri, fileName: String, relativePath: String): Uri? { + val projection = arrayOf(MediaStore.MediaColumns._ID) + val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} = ?" + val selectionArgs = arrayOf(fileName, relativePath) + return remoteSideContext.androidContext.contentResolver.query( + collection, + projection, + selection, + selectionArgs, + null + )?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) + ContentUris.withAppendedId(collection, id) + } + } + + private fun contentMatches(uri: Uri, inputFile: File): Boolean { + val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(uri) ?: return false + return streamsMatch(existingInputStream, inputFile.inputStream()) + } + + private fun filesMatch(existingFile: File, inputFile: File): Boolean { + return streamsMatch(existingFile.inputStream(), inputFile.inputStream()) + } + + private fun streamsMatch(existingInputStream: InputStream, inputInputStream: InputStream): Boolean { + existingInputStream.use { currentExistingInputStream -> + val buffer1 = ByteArray(1024 * 1024) + val buffer2 = ByteArray(1024 * 1024) + var read1: Int + var read2: Int + + inputInputStream.use { inputStream -> + while (true) { + read1 = inputStream.read(buffer1) + read2 = currentExistingInputStream.read(buffer2) + if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) { + return false + } + if (read1 == -1) break + } + } + } + return true + } + private fun saveToSystemDefault( fileName: String, fileType: FileType, inputFile: File, metadata: DownloadMetadata, - ): Uri? { - val subPath = metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "") - .replace("\\", "/") - .trim('/') + ): GallerySaveResult? { + val subPath = sanitizeRelativePath( + metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "") + .replace("\\", "/") + .trim('/') + ) val baseRelative = when { fileType.isImage -> Environment.DIRECTORY_PICTURES fileType.isVideo -> Environment.DIRECTORY_MOVIES @@ -228,23 +360,69 @@ class DownloadProcessor ( fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) } - val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - } val resolver = remoteSideContext.androidContext.contentResolver - val uri = resolver.insert(collection, values) ?: return null - resolver.openOutputStream(uri)?.use { out -> - inputFile.inputStream().use { it.copyTo(out) } - } ?: return null - uri + val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName -> + val extension = fileName.substringAfterLast('.', "") + if (extension.isBlank()) baseName else "$baseName.$extension" + } + findExistingMediaUri(collection, sanitizedFileName, relativePath)?.let { existingUri -> + if (contentMatches(existingUri, inputFile)) { + remoteSideContext.log.verbose("Media already exists in gallery: $sanitizedFileName") + return GallerySaveResult(existingUri, alreadyDownloaded = true) + } + } + + for (attempt in 0..100) { + val candidateName = if (attempt == 0) sanitizedFileName else appendNameSuffix(sanitizedFileName, attempt) + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName) + put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + val uri = runCatching { + resolver.insert(collection, values) + }.onFailure { + remoteSideContext.log.verbose("MediaStore insert rejected $candidateName in $relativePath: ${it.message}") + }.getOrNull() ?: continue + + runCatching { + resolver.openOutputStream(uri)?.use { out -> + inputFile.inputStream().use { it.copyTo(out) } + } ?: throw IllegalStateException("Failed to open output stream for $candidateName") + + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + }.also { resolver.update(uri, it, null, null) } + + return GallerySaveResult(uri) + }.onFailure { + runCatching { resolver.delete(uri, null, null) } + remoteSideContext.log.error("Failed writing media to gallery for $candidateName", it) + } + } + + throw IllegalStateException("Failed to allocate a unique gallery file for $sanitizedFileName in $relativePath") } else { @Suppress("DEPRECATION") val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative) val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else "")) destDir.mkdirs() - val destFile = File(destDir, fileName) + val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName -> + val extension = fileName.substringAfterLast('.', "") + if (extension.isBlank()) baseName else "$baseName.$extension" + } + var destFile = File(destDir, sanitizedFileName) + if (destFile.exists()) { + if (destFile.length() == inputFile.length() && filesMatch(destFile, inputFile)) { + return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true) + } + var suffix = 1 + while (destFile.exists()) { + destFile = File(destDir, appendNameSuffix(sanitizedFileName, suffix++)) + } + } FileOutputStream(destFile).use { out -> inputFile.inputStream().use { it.copyTo(out) } } @@ -255,7 +433,7 @@ class DownloadProcessor ( } ) } - Uri.fromFile(destFile) + GallerySaveResult(Uri.fromFile(destFile)) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt index c9b824b0..ec9dfa44 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt @@ -213,6 +213,7 @@ class TasksRootSection : Routes.Route() { message: String, showDeleteFiles: Boolean, deleteFilesChecked: Boolean, + tasksTranslation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper, onToggleDeleteFiles: (Boolean) -> Unit, onConfirm: () -> Unit, onDismiss: () -> Unit @@ -252,9 +253,49 @@ class TasksRootSection : Routes.Route() { } if (showDeleteFiles) { +<<<<<<< themes Row(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(Color.White.copy(alpha = 0.04f)).clickable { onToggleDeleteFiles(!deleteFilesChecked) }.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { Text(text = translation["clear_tasks_delete_files"], fontSize = 14.sp, color = Color.White.copy(alpha = 0.9f)) Checkbox(checked = deleteFilesChecked, onCheckedChange = null, colors = CheckboxDefaults.colors(checkedColor = PurrfectPalette.glowPrimary, uncheckedColor = Color.White.copy(alpha = 0.3f), checkmarkColor = Color.White)) +======= + Surface( + shape = RoundedCornerShape(18.dp), + color = Color.White.copy(alpha = 0.04f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggleDeleteFiles(!deleteFilesChecked) } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Checkbox( + checked = deleteFilesChecked, + onCheckedChange = { onToggleDeleteFiles(it) }, + colors = CheckboxDefaults.colors( + checkedColor = PurrfectPalette.glowPrimary, + uncheckedColor = Color.White, + checkmarkColor = Color.Black + ) + ) + Column { + Text( + text = tasksTranslation["delete_files_option"], + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + Text( + text = tasksTranslation.getOrNull("delete_files_option_hint") ?: "Permanently remove the original files from storage", + color = PurrfectPalette.textSecondary, + style = MaterialTheme.typography.bodySmall + ) + } + } +>>>>>>> dev } } @@ -284,7 +325,70 @@ class TasksRootSection : Routes.Route() { Icon(Icons.Filled.CheckCircle, contentDescription = text, tint = Color.White) } } +<<<<<<< themes Text(text = text, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold), color = Color.White) +======= + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold), + color = Color.White + ) + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + var showConfirmDialog by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + if (taskSelection.size > 1) { + val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) { + taskSelection.all { it.second?.type?.contains("video") == true } + } + + if (canMergeSelection) { + TopBarActionButton( + onClick = { + mergeSelection(taskSelection.toList().also { + taskSelection.clear() + }.map { it.first to it.second!! }) + }, + icon = Icons.Filled.Merge, + text = translation["merge_button"] + ) + } + } + + IconButton(onClick = { + showConfirmDialog = true + }) { + Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"]) + } + + if (showConfirmDialog) { + var alsoDeleteFiles by remember { mutableStateOf(false) } + val isSelection = taskSelection.isNotEmpty() + val titleText = if (isSelection) { + translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString()) + } else { + translation["remove_all_tasks_confirm"] + } + val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"] + + TaskDangerDialog( + visible = showConfirmDialog, + title = titleText, + message = messageText, + showDeleteFiles = isSelection, + deleteFilesChecked = alsoDeleteFiles, + tasksTranslation = translation, + onToggleDeleteFiles = { alsoDeleteFiles = it }, + onConfirm = { + showConfirmDialog = false + clearTasks(alsoDeleteFiles, coroutineScope) + }, + onDismiss = { showConfirmDialog = false } + ) +>>>>>>> dev } } @@ -431,6 +535,7 @@ class TasksRootSection : Routes.Route() { message = messageText ?: "", showDeleteFiles = isSelection, deleteFilesChecked = alsoDeleteFiles, + tasksTranslation = translation, onToggleDeleteFiles = { alsoDeleteFiles = it }, onConfirm = { showConfirmDialog = false diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index 935523a7..4ae79ff1 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "هل أنت متأكد أنك تريد إزالة المهام المحددة؟", "remove_all_tasks_title": "هل أنت متأكد أنك تريد إزالة جميع المهام؟", "delete_files_option": "حذف الملفات أيضاً", + "delete_files_option_hint": "إزالة الملفات المحملة من الجهاز أيضاً", "remove_selected_tasks_confirm": "إزالة {count} مهام؟", "remove_all_tasks_confirm": "إزالة جميع المهام؟" }, @@ -3035,7 +3036,15 @@ "title": "تنزيل وسائط dash", "download_all": "تنزيل الكل", "segment_text": "جزء {from} - {to}" - } + }, + "story_snap_dialog": { + "title": "تنزيل سنابات القصة", + "select_all": "تحديد الكل", + "deselect_all": "إلغاء التحديد", + "snap_item": "السناب {index} من {total}" + }, + "batch_download_complete_toast": "تم تنزيل جميع السنابات", + "batch_download_jump_failed_toast": "تعذر الانتقال للسناب التالي. تأكد من أن عرض القصة مرئي." }, "streaks_reminder": { "notification_title": "الستريك (Streaks)", diff --git a/common/src/main/assets/lang/bn.json b/common/src/main/assets/lang/bn.json index 99e73e84..9e27c4bf 100644 --- a/common/src/main/assets/lang/bn.json +++ b/common/src/main/assets/lang/bn.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "আপনি কি নির্বাচিত টাস্কগুলো সরাতে চান?", "remove_all_tasks_title": "আপনি কি সব টাস্ক সরাতে চান?", "delete_files_option": "ফাইলগুলোও ডিলিট করুন", + "delete_files_option_hint": "স্টোরেজ থেকে মূল ফাইলগুলো স্থায়ীভাবে সরান", "remove_selected_tasks_confirm": "{count} টি টাস্ক সরাবেন?", "remove_all_tasks_confirm": "সব টাস্ক সরাবেন?" }, diff --git a/common/src/main/assets/lang/da.json b/common/src/main/assets/lang/da.json index 5eef53d4..f940b1fe 100644 --- a/common/src/main/assets/lang/da.json +++ b/common/src/main/assets/lang/da.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Er du sikker på, at du vil fjerne valgte opgaver?", "remove_all_tasks_title": "Er du sikker på, at du vil fjerne alle opgaver?", "delete_files_option": "Slet også filer", + "delete_files_option_hint": "Fjern også downloadede filer fra enheden", "remove_selected_tasks_confirm": "Fjern {count} opgaver?", "remove_all_tasks_confirm": "Fjern alle opgaver?" }, diff --git a/common/src/main/assets/lang/de_DE.json b/common/src/main/assets/lang/de_DE.json index 1c5b85a2..1133dc85 100644 --- a/common/src/main/assets/lang/de_DE.json +++ b/common/src/main/assets/lang/de_DE.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Möchtest du die ausgewählten Aufgaben wirklich entfernen?", "remove_all_tasks_title": "Möchtest du wirklich alle Aufgaben entfernen?", "delete_files_option": "Auch Dateien löschen", + "delete_files_option_hint": "Heruntergeladene Dateien auch vom Gerät entfernen", "remove_selected_tasks_confirm": "{count} Aufgaben entfernen?", "remove_all_tasks_confirm": "Alle Aufgaben entfernen?" }, diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json index 1623f896..a347e8ac 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Are you sure you want to remove selected tasks?", "remove_all_tasks_title": "Are you sure you want to remove all tasks?", "delete_files_option": "Also delete files", + "delete_files_option_hint": "Also remove downloaded files from device", "remove_selected_tasks_confirm": "Remove {count} tasks?", "remove_all_tasks_confirm": "Remove all tasks?" }, diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 9b96bbf5..7f2d7a96 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -392,7 +392,42 @@ "groups_empty_title": "No groups synced yet", "streaks_expiration_short": "{hours}h", "social_tagline": "Manage scopes, streaks, and previews", - "social_empty_hint": "Tap the + button to sync friends or groups." + "social_empty_hint": "Tap the + button to sync friends or groups.", + "messaging_preview": { + "bridge_connection_failed": "Failed to connect to bridge. Make sure Snapchat is running in the background", + "bridge_connection_error": "Failed to connect to bridge. Make sure Snapchat is running in the background", + "bridge_init_failed": "Failed to initialize messaging bridge. Make sure Snapchat is running in the background", + "message_fetch_failed": "Failed to fetch messages", + "no_message_hint": "No message", + "sender_unknown": "Unknown", + "sender_you": "You", + "sender_friend": "Friend", + "subtitle": "Hold to select", + "actions_title": "Conversation Actions", + "choose_message_types_subtitle": "Choose message types", + "choose_message_type_subtitle": "Choose message types", + "save_selection_option": "Save Selection", + "save_all_option": "Save All", + "save_selected_messages_subtitle": "Save selected messages", + "save_by_content_type_subtitle": "Save by content type", + "unsave_selection_option": "Unsave Selection", + "unsave_all_option": "Unsave All", + "unsave_selected_messages_subtitle": "Unsave selected messages", + "unsave_by_content_type_subtitle": "Unsave by content type", + "unsave_by_content_ttype_subtitle": "Unsave by content type", + "mark_selection_as_seen_option": "Mark selected Snap as seen", + "mark_all_as_seen_option": "Mark all Snaps as seen", + "mark_all_as_as_seen_option": "Mark all Snaps as seen", + "mark_as_seen_subtitle": "Marks snaps as seen", + "delete_selection_option": "Delete Selection", + "delete_all_option": "Delete All", + "delete_selected_messages_subtitle": "Delete selected messages", + "delete_by_content_type_subtitle": "Delete by content type", + "processed_message_toast": "Processed {count} messages", + "processed_messages_toast": "Processed {count} messages", + "processed_messages_text": "Processed {count}", + "close_button_description": "Clear selection" + } }, "manage_scope": { "manage_scope_title": "Manage", @@ -982,6 +1017,10 @@ "name": "Opera Download Button", "description": "Adds a download button on the top right corner when viewing a Snap.\nLong press on buttons will force download" }, + "story_snap_list_download": { + "name": "Story Snap List Download", + "description": "When viewing a multi-snap story, shows a dialog to select which snaps to download" + }, "download_context_menu": { "name": "Download Context Menu", "description": "Allows you to download/preview messages from a conversation or a story using the context menu.\nLong press on buttons will force download" @@ -3048,7 +3087,15 @@ "title": "Download dash media", "download_all": "Download All", "segment_text": "Segment {from} - {to}" - } + }, + "story_snap_dialog": { + "title": "Download story snaps", + "select_all": "Select All", + "deselect_all": "Deselect All", + "snap_item": "Snap {index} of {total}" + }, + "batch_download_complete_toast": "All snaps downloaded", + "batch_download_jump_failed_toast": "Could not navigate to next snap. Ensure Story Snap Jump is enabled and the story view is visible." }, "streaks_reminder": { "notification_title": "Streaks", diff --git a/common/src/main/assets/lang/es_ES.json b/common/src/main/assets/lang/es_ES.json index 7768e849..4ea5a414 100644 --- a/common/src/main/assets/lang/es_ES.json +++ b/common/src/main/assets/lang/es_ES.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "¿Estás seguro de que quieres eliminar las tareas seleccionadas?", "remove_all_tasks_title": "¿Estás seguro de que quieres eliminar todas las tareas?", "delete_files_option": "También eliminar archivos", + "delete_files_option_hint": "También eliminar archivos descargados del dispositivo", "remove_selected_tasks_confirm": "¿Eliminar {count} tareas?", "remove_all_tasks_confirm": "¿Eliminar todas las tareas?" }, diff --git a/common/src/main/assets/lang/fi.json b/common/src/main/assets/lang/fi.json index 3ae2dd66..616c6136 100644 --- a/common/src/main/assets/lang/fi.json +++ b/common/src/main/assets/lang/fi.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Oletko varma, että haluat poistaa valitut tehtävät?", "remove_all_tasks_title": "Oletko varma, että haluat poistaa kaikki tehtävät?", "delete_files_option": "Poista myös tiedostot", + "delete_files_option_hint": "Poista myös ladatut tiedostot laitteelta", "remove_selected_tasks_confirm": "Poista {count} tehtävää?", "remove_all_tasks_confirm": "Poista kaikki tehtävät?" }, diff --git a/common/src/main/assets/lang/fr_FR.json b/common/src/main/assets/lang/fr_FR.json index ca8d6962..c15752b9 100644 --- a/common/src/main/assets/lang/fr_FR.json +++ b/common/src/main/assets/lang/fr_FR.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Êtes-vous sûr de vouloir supprimer les tâches sélectionnées ?", "remove_all_tasks_title": "Êtes-vous sûr de vouloir supprimer toutes les tâches ?", "delete_files_option": "Supprimer aussi les fichiers", + "delete_files_option_hint": "Supprimer aussi les fichiers téléchargés de l'appareil", "remove_selected_tasks_confirm": "Supprimer {count} tâches ?", "remove_all_tasks_confirm": "Supprimer toutes les tâches ?" }, diff --git a/common/src/main/assets/lang/hi_IN.json b/common/src/main/assets/lang/hi_IN.json index e20a13dd..ac72530d 100644 --- a/common/src/main/assets/lang/hi_IN.json +++ b/common/src/main/assets/lang/hi_IN.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "क्या आप वाकई चयनित कार्यों को हटाना चाहते हैं?", "remove_all_tasks_title": "क्या आप वाकई सभी कार्यों को हटाना चाहते हैं?", "delete_files_option": "फ़ाइलें भी हटाएँ", + "delete_files_option_hint": "स्टोरेज से मूल फ़ाइलों को स्थायी रूप से हटाएँ", "remove_selected_tasks_confirm": "{count} कार्य हटाएँ?", "remove_all_tasks_confirm": "सभी कार्य हटाएँ?" }, diff --git a/common/src/main/assets/lang/hu_HU.json b/common/src/main/assets/lang/hu_HU.json index eebe9377..dc290414 100644 --- a/common/src/main/assets/lang/hu_HU.json +++ b/common/src/main/assets/lang/hu_HU.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Biztosan törölni szeretnéd a kijelölt feladatokat?", "remove_all_tasks_title": "Biztosan törölni szeretnéd az összes feladatot?", "delete_files_option": "Fájlok törlése is", + "delete_files_option_hint": "Az eredeti fájlok végleges eltávolítása a tárolóból", "remove_selected_tasks_confirm": "{count} feladat törlése?", "remove_all_tasks_confirm": "Összes feladat törlése?" }, diff --git a/common/src/main/assets/lang/id.json b/common/src/main/assets/lang/id.json index 2f381415..372ac923 100644 --- a/common/src/main/assets/lang/id.json +++ b/common/src/main/assets/lang/id.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Apakah Anda yakin ingin menghapus tugas yang dipilih?", "remove_all_tasks_title": "Apakah Anda yakin ingin menghapus semua tugas?", "delete_files_option": "Hapus juga file", + "delete_files_option_hint": "Hapus juga file yang diunduh dari perangkat", "remove_selected_tasks_confirm": "Hapus {count} tugas?", "remove_all_tasks_confirm": "Hapus semua tugas?" }, diff --git a/common/src/main/assets/lang/it_IT.json b/common/src/main/assets/lang/it_IT.json index 260abaf6..1c1cb688 100644 --- a/common/src/main/assets/lang/it_IT.json +++ b/common/src/main/assets/lang/it_IT.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Sei sicuro di voler rimuovere le attività selezionate?", "remove_all_tasks_title": "Sei sicuro di voler rimuovere tutte le attività?", "delete_files_option": "Elimina anche i file", + "delete_files_option_hint": "Rimuovi anche i file scaricati dal dispositivo", "remove_selected_tasks_confirm": "Rimuovere {count} attività?", "remove_all_tasks_confirm": "Rimuovere tutte le attività?" }, diff --git a/common/src/main/assets/lang/ja_JP.json b/common/src/main/assets/lang/ja_JP.json index 8d0917e0..60bc006c 100644 --- a/common/src/main/assets/lang/ja_JP.json +++ b/common/src/main/assets/lang/ja_JP.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "選択したタスクを削除しますか?", "remove_all_tasks_title": "すべてのタスクを削除しますか?", "delete_files_option": "ファイルも削除する", + "delete_files_option_hint": "ストレージから元のファイルを完全に削除する", "remove_selected_tasks_confirm": "{count} 個のタスクを削除しますか?", "remove_all_tasks_confirm": "すべてのタスクを削除しますか?" }, diff --git a/common/src/main/assets/lang/ko_KR.json b/common/src/main/assets/lang/ko_KR.json index 7fdcc236..295115de 100644 --- a/common/src/main/assets/lang/ko_KR.json +++ b/common/src/main/assets/lang/ko_KR.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "선택한 작업을 제거하시겠습니까?", "remove_all_tasks_title": "모든 작업을 제거하시겠습니까?", "delete_files_option": "파일도 삭제", + "delete_files_option_hint": "저장소에서 원본 파일을 영구적으로 제거", "remove_selected_tasks_confirm": "{count}개의 작업 제거?", "remove_all_tasks_confirm": "모든 작업 제거?" }, diff --git a/common/src/main/assets/lang/ku_KU.json b/common/src/main/assets/lang/ku_KU.json index f1ca7dce..dd4c1fab 100644 --- a/common/src/main/assets/lang/ku_KU.json +++ b/common/src/main/assets/lang/ku_KU.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "دڵنیایت دەتەوێت ئەرکە دیاریکراوەکان بسڕیتەوە؟", "remove_all_tasks_title": "دڵنیایت دەتەوێت هەموو ئەرکەکان بسڕیتەوە؟", "delete_files_option": "فایلەکانیش بسڕەوە", + "delete_files_option_hint": "فایلە ئەسڵییەکان بە هەمیشەیی لە کۆگەوە بسڕەوە", "remove_selected_tasks_confirm": "{count} ئەرک بسڕدرێتەوە؟", "remove_all_tasks_confirm": "هەموو ئەرکەکان بسڕدرێتەوە؟" }, diff --git a/common/src/main/assets/lang/lv_LV.json b/common/src/main/assets/lang/lv_LV.json index ff5ff369..f2b554fd 100644 --- a/common/src/main/assets/lang/lv_LV.json +++ b/common/src/main/assets/lang/lv_LV.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Vai tiešām vēlaties noņemt atlasītos uzdevumus?", "remove_all_tasks_title": "Vai tiešām vēlaties noņemt visus uzdevumus?", "delete_files_option": "Dzēst arī failus", + "delete_files_option_hint": "Noņemt arī lejupielādētos failus no ierīces", "remove_selected_tasks_confirm": "Noņemt {count} uzdevumus?", "remove_all_tasks_confirm": "Noņemt visus uzdevumus?" }, diff --git a/common/src/main/assets/lang/nb_NO.json b/common/src/main/assets/lang/nb_NO.json index 2371eae1..b4b06bac 100644 --- a/common/src/main/assets/lang/nb_NO.json +++ b/common/src/main/assets/lang/nb_NO.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Er du sikker på at du vil fjerne valgte oppgaver?", "remove_all_tasks_title": "Er du sikker på at du vil fjerne alle oppgaver?", "delete_files_option": "Slett også filer", + "delete_files_option_hint": "Fjern permanent de originale filene fra lagring", "remove_selected_tasks_confirm": "Fjerne {count} oppgaver?", "remove_all_tasks_confirm": "Fjerne alle oppgaver?" }, diff --git a/common/src/main/assets/lang/nl.json b/common/src/main/assets/lang/nl.json index 0cee8ab2..474b2e0a 100644 --- a/common/src/main/assets/lang/nl.json +++ b/common/src/main/assets/lang/nl.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Weet je zeker dat je de geselecteerde taken wilt verwijderen?", "remove_all_tasks_title": "Weet je zeker dat je alle taken wilt verwijderen?", "delete_files_option": "Ook bestanden verwijderen", + "delete_files_option_hint": "Ook gedownloade bestanden van apparaat verwijderen", "remove_selected_tasks_confirm": "{count} taken verwijderen?", "remove_all_tasks_confirm": "Alle taken verwijderen?" }, diff --git a/common/src/main/assets/lang/pl.json b/common/src/main/assets/lang/pl.json index 9f815107..036e51c3 100644 --- a/common/src/main/assets/lang/pl.json +++ b/common/src/main/assets/lang/pl.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Czy na pewno chcesz usunąć wybrane zadania?", "remove_all_tasks_title": "Czy na pewno chcesz usunąć wszystkie zadania?", "delete_files_option": "Usuń również pliki", + "delete_files_option_hint": "Usuń również pobrane pliki z urządzenia", "remove_selected_tasks_confirm": "Usunąć {count} zadań?", "remove_all_tasks_confirm": "Usunąć wszystkie zadania?" }, diff --git a/common/src/main/assets/lang/pt.json b/common/src/main/assets/lang/pt.json index b050b364..98f03311 100644 --- a/common/src/main/assets/lang/pt.json +++ b/common/src/main/assets/lang/pt.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Tem certeza que deseja remover as tarefas selecionadas?", "remove_all_tasks_title": "Tem certeza que deseja remover todas as tarefas?", "delete_files_option": "Excluir arquivos também", + "delete_files_option_hint": "Remover permanentemente os arquivos originais do armazenamento", "remove_selected_tasks_confirm": "Remover {count} tarefas?", "remove_all_tasks_confirm": "Remover todas as tarefas?" }, diff --git a/common/src/main/assets/lang/ro.json b/common/src/main/assets/lang/ro.json index 5ef51485..aa748d5a 100644 --- a/common/src/main/assets/lang/ro.json +++ b/common/src/main/assets/lang/ro.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Ești sigur că vrei să elimini sarcinile selectate?", "remove_all_tasks_title": "Ești sigur că vrei să elimini toate sarcinile?", "delete_files_option": "Șterge și fișierele", + "delete_files_option_hint": "Elimină permanent fișierele originale din stocare", "remove_selected_tasks_confirm": "Elimină {count} sarcini?", "remove_all_tasks_confirm": "Elimină toate sarcinile?" }, diff --git a/common/src/main/assets/lang/ru.json b/common/src/main/assets/lang/ru.json index c2c28bee..77ef67bf 100644 --- a/common/src/main/assets/lang/ru.json +++ b/common/src/main/assets/lang/ru.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Вы уверены, что хотите удалить выбранные задачи?", "remove_all_tasks_title": "Вы уверены, что хотите удалить все задачи?", "delete_files_option": "Также удалить файлы", + "delete_files_option_hint": "Также удалить загруженные файлы с устройства", "remove_selected_tasks_confirm": "Удалить {count} задач?", "remove_all_tasks_confirm": "Удалить все задачи?" }, diff --git a/common/src/main/assets/lang/sl_SI.json b/common/src/main/assets/lang/sl_SI.json index f5606cb5..52dc4767 100644 --- a/common/src/main/assets/lang/sl_SI.json +++ b/common/src/main/assets/lang/sl_SI.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Si prepričan, da želiš odstraniti izbrana opravila?", "remove_all_tasks_title": "Si prepričan, da želiš odstraniti vsa opravila?", "delete_files_option": "Izbriši tudi datoteke", + "delete_files_option_hint": "Trajno odstrani izvirne datoteke iz pomnilnika", "remove_selected_tasks_confirm": "Odstrani {count} opravil?", "remove_all_tasks_confirm": "Odstrani vsa opravila?" }, diff --git a/common/src/main/assets/lang/sv.json b/common/src/main/assets/lang/sv.json index 2f7391c9..bd73bf5b 100644 --- a/common/src/main/assets/lang/sv.json +++ b/common/src/main/assets/lang/sv.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Är du säker på att du vill ta bort valda uppgifter?", "remove_all_tasks_title": "Är du säker på att du vill ta bort alla uppgifter?", "delete_files_option": "Radera även filer", + "delete_files_option_hint": "Ta bort nedladdade filer från enheten", "remove_selected_tasks_confirm": "Ta bort {count} uppgifter?", "remove_all_tasks_confirm": "Ta bort alla uppgifter?" }, diff --git a/common/src/main/assets/lang/tr_TR.json b/common/src/main/assets/lang/tr_TR.json index f74e1e70..cf0c9d0f 100644 --- a/common/src/main/assets/lang/tr_TR.json +++ b/common/src/main/assets/lang/tr_TR.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Seçili görevleri kaldırmak istediğinizden emin misiniz?", "remove_all_tasks_title": "Tüm görevleri kaldırmak istediğinizden emin misiniz?", "delete_files_option": "Dosyaları da sil", + "delete_files_option_hint": "Depolamadan özgün dosyaları kalıcı olarak kaldır", "remove_selected_tasks_confirm": "{count} görevi kaldır?", "remove_all_tasks_confirm": "Tüm görevleri kaldır?" }, diff --git a/common/src/main/assets/lang/uk_UA.json b/common/src/main/assets/lang/uk_UA.json index fed67e10..85531268 100644 --- a/common/src/main/assets/lang/uk_UA.json +++ b/common/src/main/assets/lang/uk_UA.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Ви впевнені, що хочете видалити вибрані завдання?", "remove_all_tasks_title": "Ви впевнені, що хочете видалити всі завдання?", "delete_files_option": "Також видалити файли", + "delete_files_option_hint": "Також видалити завантажені файли з пристрою", "remove_selected_tasks_confirm": "Видалити {count} завдань?", "remove_all_tasks_confirm": "Видалити всі завдання?" }, diff --git a/common/src/main/assets/lang/zh_SIMPLIFIED.json b/common/src/main/assets/lang/zh_SIMPLIFIED.json index 2b2bb77b..25566132 100644 --- a/common/src/main/assets/lang/zh_SIMPLIFIED.json +++ b/common/src/main/assets/lang/zh_SIMPLIFIED.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "您确定要删除选定的任务吗?", "remove_all_tasks_title": "您确定要删除所有任务吗?", "delete_files_option": "同时删除文件", + "delete_files_option_hint": "从存储中永久删除原始文件", "remove_selected_tasks_confirm": "删除 {count} 个任务?", "remove_all_tasks_confirm": "删除所有任务?" }, diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt index 7c3b9c33..6ca1eeb4 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt @@ -44,6 +44,7 @@ class DownloaderConfig : ConfigContainer() { val autoDownloadVoiceNotes = boolean("auto_download_voice_notes") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val downloadProfilePictures = boolean("download_profile_pictures") { requireRestart() } val operaDownloadButton = boolean("opera_download_button") { requireRestart() } + val storySnapListDownload = boolean("story_snap_list_download", true) val downloadContextMenu = boolean("download_context_menu") val ffmpegOptions = container("ffmpeg_options", FFMpegOptions()) { addNotices(FeatureNotice.UNSTABLE) } val logging = multiple("logging", "started", "success", "progress", "failure").apply { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt index 8f14a4e0..5594e326 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt @@ -21,6 +21,8 @@ import me.eternal.purrfectsnap.common.data.FriendLinkType import me.eternal.purrfectsnap.common.bridge.FileHandleScope import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType import me.eternal.purrfectsnap.common.bridge.toWrapper +import me.eternal.purrfectsnap.common.database.impl.FriendFeedEntry +import me.eternal.purrfectsnap.common.database.impl.FriendInfo import me.eternal.purrfectsnap.common.data.FriendStreaks import me.eternal.purrfectsnap.common.data.MessagingFriendInfo import me.eternal.purrfectsnap.common.data.MessagingGroupInfo @@ -54,6 +56,23 @@ class PurrfectSnap { private var android9ValdiBindDisabled = false private var android9ValdiBindDisableLogged = false private val nativeLateInitTriggered = java.util.concurrent.atomic.AtomicBoolean(false) + private var syncCallback: SyncCallback? = null + + private fun FriendInfo.isCurrentSocialFriend(): Boolean { + return !userId.isNullOrBlank() && + FriendLinkType.fromValue(friendLinkType) == FriendLinkType.MUTUAL && + addedTimestamp > 0L + } + + private fun FriendFeedEntry.toMessagingGroupInfo(): MessagingGroupInfo? { + if (conversationType != 1 || participantsSize <= 0) return null + val conversationId = key?.takeIf { it.isNotBlank() } ?: return null + return MessagingGroupInfo( + conversationId = conversationId, + name = feedDisplayName ?: "", + participantsCount = participantsSize + ) + } private fun hookMainActivity(methodName: String, stage: HookStage = HookStage.AFTER, block: Activity.(param: HookAdapter) -> Unit) { Activity::class.java.hook(methodName, stage, { isBridgeInitialized }) { param -> @@ -447,24 +466,15 @@ class PurrfectSnap { event.canceled = true val feedEntries = appContext.database.getFeedEntries(Int.MAX_VALUE) - val groups = feedEntries.filter { it.conversationType == 1 }.map { - MessagingGroupInfo( - it.key!!, - it.feedDisplayName ?: "", - it.participantsSize - ) - } + val groups = feedEntries + .asSequence() + .mapNotNull { it.toMessagingGroupInfo() } + .distinctBy { it.conversationId } + .toList() val friends = appContext.database.getAllFriends() .asSequence() - .filter { friend -> - friend.userId != null && when (FriendLinkType.fromValue(friend.friendLinkType)) { - FriendLinkType.DELETED, - FriendLinkType.BLOCKED, - FriendLinkType.SUGGESTED -> false - else -> true - } - } + .filter { friend -> friend.isCurrentSocialFriend() } .mapNotNull { friend -> val userId = friend.userId ?: return@mapNotNull null MessagingFriendInfo( @@ -484,22 +494,20 @@ class PurrfectSnap { } private fun syncRemote() { - if (!appContext.isLoggedIn()) return + if (!appContext.isLoggedIn()) { + syncCallback = null + return + } val myUserId = appContext.database.myUserId val streakEntries = appContext.database.getFeedEntries(Int.MAX_VALUE, whereClause = "streak_count IS NOT NULL AND streak_count > 0") .associateBy { entry -> (entry.friendUserId ?: entry.participants?.firstOrNull { it != myUserId }) } .filter { it.key != null } - appContext.bridgeClient.sync(object : SyncCallback.Stub() { + syncCallback = object : SyncCallback.Stub() { override fun syncFriend(uuid: String): String? { return appContext.database.getFriendInfo(uuid)?.let { - if (FriendLinkType.fromValue(it.friendLinkType) in setOf( - FriendLinkType.DELETED, - FriendLinkType.BLOCKED, - FriendLinkType.SUGGESTED - ) - ) return@let null + if (!it.isCurrentSocialFriend()) return@let null MessagingFriendInfo( userId = it.userId!!, dmConversationId = appContext.database.getDMConversationId(it.userId!!), @@ -531,7 +539,9 @@ class PurrfectSnap { ).toSerialized() } } - }) + } + + appContext.bridgeClient.sync(syncCallback!!) } private fun jetpackComposeResourceHook() { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 81db8a99..5093b81a 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -88,6 +88,7 @@ class FeatureManager( AutoReply(), UITweaks(), OperaStoryCounter(), + OperaStoryOverlay(), ConfigurationOverride(), COFOverride(), UnsaveableMessages(), @@ -124,7 +125,7 @@ class FeatureManager( PreventForcedLogout(), ConversationToolbox(), SpotlightCommentsUsername(), - SpotlightCreatorInfo(), + OperaStoryCounter(), OperaViewerParamsOverride(), StealthModeIndicator(), DisablePermissionRequests(), @@ -142,6 +143,7 @@ class FeatureManager( AutoOpenSnaps(), CustomStreaksExpirationFormat(), ValdiHooks(), + FirstCreatedUsername(), DisableCustomTabs(), BestFriendPinning(), ContextMenuFix(), diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/CallRecorder.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/CallRecorder.kt index 14b92f78..7b4a9b24 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/CallRecorder.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/CallRecorder.kt @@ -1,9 +1,11 @@ package me.eternal.purrfectsnap.core.features.impl.downloader +import android.media.AudioManager import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioRecord import android.media.AudioTrack +import android.media.MediaRecorder import android.os.ParcelFileDescriptor import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -223,18 +225,22 @@ class CallRecorder : Feature("Call Recorder") { if (recorderConfig == "only_record_others") return@apply hookConstructor(HookStage.AFTER) { param -> val attributes = runCatching { param.arg(0) }.getOrNull() - val isCall = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION || - attributes?.usage == AudioAttributes.USAGE_UNKNOWN || - runCatching { param.arg(0) }.getOrNull() == 7 // 7 = VOICE_COMMUNICATION - - if (isCall) { + val audioSource = runCatching { param.arg(0) }.getOrNull() + val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION || + audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION + val shouldCapture = isVoiceCommunication || + (wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN) + + if (shouldCapture) { val format = AudioFormat.Builder() .setSampleRate(if (attributes != null) param.arg(1).sampleRate else param.arg(1)) .setChannelMask(if (attributes != null) param.arg(1).channelMask else param.arg(2)) .setEncoding(if (attributes != null) param.arg(1).encoding else param.arg(3)) .build() streams[param.thisObject().hashCode()] = CallStreamWrapper(format) - ensureSessionStarted() + if (isVoiceCommunication) { + ensureSessionStarted() + } } } @@ -273,18 +279,23 @@ class CallRecorder : Feature("Call Recorder") { if (recorderConfig == "only_record_self") return@apply hookConstructor(HookStage.AFTER) { param -> val attributes = runCatching { param.arg(0) }.getOrNull() - val isCall = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION || - attributes?.usage == AudioAttributes.USAGE_UNKNOWN || - runCatching { param.arg(0) }.getOrNull() in listOf(0, 7) // 0 = CALL, 7 = SCO - - if (isCall) { + val streamType = runCatching { param.arg(0) }.getOrNull() + val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION || + streamType == AudioManager.STREAM_VOICE_CALL || + streamType == 6 + val shouldCapture = isVoiceCommunication || + (wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN) + + if (shouldCapture) { val format = AudioFormat.Builder() .setSampleRate(if (attributes != null) param.arg(1).sampleRate else param.arg(1)) .setChannelMask(if (attributes != null) param.arg(1).channelMask else param.arg(2)) .setEncoding(if (attributes != null) param.arg(1).encoding else param.arg(3)) .build() streams[param.thisObject().hashCode()] = CallStreamWrapper(format) - ensureSessionStarted() + if (isVoiceCommunication) { + ensureSessionStarted() + } } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 93eec080..71b87a1d 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -10,11 +10,40 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView +import androidx.compose.foundation.background +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog +import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme import kotlinx.coroutines.runBlocking import me.eternal.purrfectsnap.bridge.DownloadCallback +import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.FileType import me.eternal.purrfectsnap.common.data.MessagingRuleType import me.eternal.purrfectsnap.common.data.download.* @@ -46,6 +75,7 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.dash.SnapPlaylistItem import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair +import me.eternal.purrfectsnap.core.features.impl.ui.OperaStoryOverlay import me.eternal.purrfectsnap.core.wrapper.impl.media.EncryptionWrapper import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper import me.eternal.purrfectsnap.core.wrapper.impl.media.SnapCipherMode @@ -66,10 +96,19 @@ class SnapChapterInfo( val duration: Long? ) +data class OperaViewerMessageContext( + val conversationId: String, + val clientMessageId: Long +) + class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD) { private var lastSeenMediaInfoMap: MutableMap? = null var lastSeenMapParams: ParamMap? = null private set + @Volatile + private var pendingBatchDownloadIndices: MutableList? = null + @Volatile + private var batchForceAllowDuplicate: Boolean = false private val translations by lazy { context.translation.getCategory("download_processor") } @@ -160,14 +199,227 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp ) } + private fun ParamMap.getStorySnapIndex(): Int? = + this["snap_index_in_story"]?.toString()?.toIntOrNull() + ?: this["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() + + private fun ParamMap.getStorySnapTotal(): Int? = + this["snap_story_length"]?.toString()?.toIntOrNull() + ?: this["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() + + private fun isMultiSnapStory(paramMap: ParamMap): Boolean { + if (paramMap.containsKey("MESSAGE_ID") || paramMap["SNAP_SOURCE"]?.toString() == "SINGLE_SNAP_STORY") return false + if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM")) return false + val total = paramMap.getStorySnapTotal() ?: return false + return total > 1 + } + /* * Download the last seen media */ fun downloadLastOperaMediaAsync(allowDuplicate: Boolean) { if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return - context.executeAsync { - handleOperaMedia(lastSeenMapParams!!, lastSeenMediaInfoMap!!, true, allowDuplicate) + val paramMap = lastSeenMapParams!! + val mediaInfoMap = lastSeenMediaInfoMap!! + + if (isMultiSnapStory(paramMap) && context.config.downloader.storySnapListDownload.get()) { + context.runOnUiThread { + showStorySnapSelectionDialog(paramMap, mediaInfoMap, allowDuplicate) + } + return } + + context.executeAsync { + handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) + } + } + + private fun showStorySnapSelectionDialog(paramMap: ParamMap, mediaInfoMap: Map, allowDuplicate: Boolean) { + val totalCount = paramMap.getStorySnapTotal() ?: return + val currentIndex = paramMap.getStorySnapIndex() ?: 0 + val tr = context.translation.getCategory("download_processor.story_snap_dialog") + val cancelStr = context.translation["button.cancel"] + val downloadStr = context.translation["button.download"] + + context.runOnUiThread { + createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + PurrfectOverlayTheme { + val selected = remember { mutableStateListOf().apply { add(currentIndex) } } + + LaunchedEffect(Unit) { + if (!selected.contains(currentIndex)) selected.add(currentIndex) + } + + PurrfectGlassCard( + title = tr["title"], + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 320.dp) + .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + itemsIndexed((0 until totalCount).toList()) { index, _ -> + val label = tr.format("snap_item", "index" to (index + 1).toString(), "total" to totalCount.toString()) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = selected.contains(index), + onCheckedChange = { checked -> + if (checked) selected.add(index) else selected.remove(index) + }, + colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) + ) + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = PurrfectOverlayPalette.textPrimary + ) + } + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = selected.size == totalCount, + onCheckedChange = { checked -> + if (checked) { + selected.clear() + selected.addAll(0 until totalCount) + } else { + selected.clear() + } + }, + colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) + ) + Text( + tr["select_all"], + style = MaterialTheme.typography.bodyMedium, + color = PurrfectOverlayPalette.textPrimary + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = { alertDialog.dismiss() }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = PurrfectOverlayPalette.textPrimary) + ) { + Text(cancelStr) + } + Button( + onClick = { + if (selected.isNotEmpty()) { + startBatchDownload(selected.sorted().toMutableList(), allowDuplicate) + alertDialog.dismiss() + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary) + ) { + Text(downloadStr) + } + } + } + } + } + }.apply { + window?.setBackgroundDrawableResource(android.R.color.transparent) + show() + } + } + } + + private fun startBatchDownload(indices: MutableList, allowDuplicate: Boolean) { + if (indices.isEmpty()) return + val paramMap = lastSeenMapParams ?: return + val mediaInfoMap = lastSeenMediaInfoMap ?: return + + pendingBatchDownloadIndices = indices + batchForceAllowDuplicate = allowDuplicate + + val currentIndex = paramMap.getStorySnapIndex() ?: 0 + val targetIndex = indices.first() + val totalCount = paramMap.getStorySnapTotal() + + if (currentIndex == targetIndex) { + processNextBatchDownload(paramMap, mediaInfoMap) + } else { + val jumped = context.feature(OperaStoryOverlay::class).requestJumpToSnap(targetIndex, totalCount) + if (!jumped) { + pendingBatchDownloadIndices = null + context.shortToast(translations["batch_download_jump_failed_toast"]) + } + } + } + + private fun downloadSingleSnap(paramMap: ParamMap, mediaInfoMap: Map) { + context.executeAsync { + runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, batchForceAllowDuplicate) } + .onFailure { + context.log.error("Batch download failed", it) + context.shortToast(translations["failed_generic_toast"]) + } + } + } + + private fun processNextBatchDownload(paramMap: ParamMap, mediaInfoMap: Map) { + val queue = pendingBatchDownloadIndices ?: return + if (queue.isEmpty()) { + flushPendingMergeAndComplete() + return + } + + val currentIndex = paramMap.getStorySnapIndex() ?: -1 + if (currentIndex != queue.first()) return + + queue.removeAt(0) + downloadSingleSnap(paramMap, mediaInfoMap) + + if (queue.isEmpty()) { + flushPendingMergeAndComplete() + } else { + val totalCount = paramMap.getStorySnapTotal() + context.runOnUiThread { + fun tryJump(retryCount: Int = 0) { + val delayMs = if (retryCount == 0) 120L else 220L + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + val jumped = runCatching { + context.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount) + }.getOrNull() == true + if (!jumped && retryCount < 1) { + tryJump(retryCount + 1) + } else if (!jumped) { + pendingBatchDownloadIndices = null + context.shortToast(translations["batch_download_jump_failed_toast"]) + } + }, delayMs) + } + tryJump() + } + } + } + + private fun flushPendingMergeAndComplete() { + pendingBatchDownloadIndices = null + context.shortToast(translations["batch_download_complete_toast"]) } fun showLastOperaDebugMediaInfo() { @@ -195,6 +447,71 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } } + private fun isSnapContentType(contentTypeId: Int): Boolean { + return when (ContentType.fromId(contentTypeId)) { + ContentType.SNAP, + ContentType.TINY_SNAP, + ContentType.EXTERNAL_MEDIA -> true + else -> false + } + } + + private fun validateViewerMessageContext(messageContext: OperaViewerMessageContext): OperaViewerMessageContext? { + val message = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return null + if (message.clientConversationId != messageContext.conversationId) return null + if (!isSnapContentType(message.contentType)) return null + return messageContext + } + + private fun parseViewerMessageContext(rawValue: String): OperaViewerMessageContext? { + val parts = rawValue.split(':') + if (parts.size < 3) return null + + val conversationId = parts.firstOrNull()?.takeIf { + runCatching { UUID.fromString(it) }.isSuccess + } ?: return null + val clientMessageId = parts.lastOrNull()?.toLongOrNull() ?: return null + + return OperaViewerMessageContext( + conversationId = conversationId, + clientMessageId = clientMessageId + ) + } + + fun resolveViewerMessageContextFromParamMap(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? { + if (paramMap == null) return null + + paramMap["MESSAGE_ID"]?.toString() + ?.let(::parseViewerMessageContext) + ?.let(::validateViewerMessageContext) + ?.let { return it } + + return paramMap.concurrentHashMap.values + .asSequence() + .mapNotNull { value -> + value?.toString()?.let(::parseViewerMessageContext) + } + .mapNotNull(::validateViewerMessageContext) + .firstOrNull() + } + + fun resolveCurrentSnapMessageContext(): OperaViewerMessageContext? { + val messaging = context.feature(Messaging::class) + val currentConversationId = messaging.openedConversationUUID?.toString() + val currentMessageId = messaging.lastFocusedMessageId.takeIf { it > 0L } + + if (currentConversationId != null && currentMessageId != null) { + validateViewerMessageContext( + OperaViewerMessageContext( + conversationId = currentConversationId, + clientMessageId = currentMessageId + ) + )?.let { return it } + } + + return resolveViewerMessageContextFromParamMap() + } + private fun handleLocalReferences(path: String) = runBlocking { Uri.parse(path).let { uri -> if (uri.scheme == "file" || uri.scheme == null) { @@ -285,9 +602,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp ) { // ─── Messages ───────────────────────── - paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id -> - val messageId = id.substringAfterLast(":").toLong() - val conversationMessage = context.database.getConversationMessageFromId(messageId) ?: return@let + resolveViewerMessageContextFromParamMap(paramMap)?.takeIf { + forceDownload || shouldAutoDownload("friend_snaps") + }?.let { messageContext -> + val conversationMessage = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return@let val conversationId = conversationMessage.clientConversationId!! if (!forceDownload && !canUseRule(conversationId)) return@let @@ -530,14 +848,26 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*> - val mediaParamMap: ParamMap = operaLayerList + val layerParamMaps = operaLayerList .asSequence() .mapNotNull { layerObj -> layerObj?.let { runCatching { Layer(it).paramMap }.getOrNull() } } - .firstOrNull { - it.containsKey("image_media_info") || it.containsKey("video_media_info_list") - } ?: return@onOperaViewStateCallback + .toList() + val firstLayerParamMap = layerParamMaps.firstOrNull() + val mediaParamMap: ParamMap = ( + // Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work. + layerParamMaps.firstOrNull { + it.containsKey("MESSAGE_ID") && + (it.containsKey("image_media_info") || it.containsKey("video_media_info_list")) + } + ?: firstLayerParamMap?.takeIf { + it.containsKey("image_media_info") || it.containsKey("video_media_info_list") + } + ?: layerParamMaps.firstOrNull { + it.containsKey("image_media_info") || it.containsKey("video_media_info_list") + } + ) ?: return@onOperaViewStateCallback val mediaInfoMap = mutableMapOf() val isVideo = mediaParamMap.containsKey("video_media_info_list") @@ -558,6 +888,15 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp lastSeenMapParams = mediaParamMap lastSeenMediaInfoMap = mediaInfoMap + if (pendingBatchDownloadIndices != null) { + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + if (pendingBatchDownloadIndices != null) { + processNextBatchDownload(mediaParamMap, mediaInfoMap) + } + }, 80L) + return@onOperaViewStateCallback + } + if (!shouldAutoDownload) { return@onOperaViewStateCallback } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/ProfilePictureDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/ProfilePictureDownloader.kt index f27b1e75..507662eb 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/ProfilePictureDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/ProfilePictureDownloader.kt @@ -2,14 +2,19 @@ package me.eternal.purrfectsnap.core.features.impl.downloader import android.annotation.SuppressLint import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Rect import android.content.res.ColorStateList import android.graphics.drawable.GradientDrawable import android.util.TypedValue -import android.widget.ImageView -import android.widget.ImageButton -import android.widget.FrameLayout +import android.view.Gravity +import android.view.MotionEvent import android.view.View -import android.view.ViewTreeObserver +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -73,10 +78,11 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") { context.event.subscribe(AddViewEvent::class) { event -> if (event.view::class.java.name !in profileViewClasses) return@subscribe - val activity = context.mainActivity ?: return@subscribe - val rootContent = activity.findViewById(android.R.id.content) ?: return@subscribe + val parent = event.parent + if (parent.findViewWithTag(DOWNLOAD_BUTTON_TAG) != null) return@subscribe + + val activity = parent.context.findActivity() ?: context.mainActivity ?: return@subscribe val buttonText = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"] - rootContent.findViewWithTag(DOWNLOAD_BUTTON_TAG)?.let { rootContent.removeView(it) } val button = ImageButton(activity).apply { val density = resources.displayMetrics.density @@ -104,7 +110,14 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { stateListAnimator = null } - layoutParams = FrameLayout.LayoutParams(buttonSize, buttonSize) + layoutParams = FrameLayout.LayoutParams( + buttonSize, + buttonSize, + Gravity.TOP or Gravity.START + ).apply { + leftMargin = (8 * density).toInt() + topMargin = 236 + } setOnClickListener { val choices = buildList { backgroundUrl?.let { @@ -128,7 +141,7 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") { } createComposeAlertDialog( - this@ProfilePictureDownloader.context.mainActivity!!, + activity, content = { alertDialog -> ProfilePictureDialog( title = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"], @@ -158,44 +171,30 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") { } } - val leftOffsetPx = (8 * activity.resources.displayMetrics.density).toInt() - val topOffsetPx = 236 - - val anchorView = event.view - val positionUpdater = ViewTreeObserver.OnPreDrawListener { - updateOverlayButtonPosition( - activity = activity, - anchorView = anchorView, - button = button, - leftOffsetPx = leftOffsetPx, - topOffsetPx = topOffsetPx + val overlayWrapper = object : FrameLayout(parent.context) { + override fun onTouchEvent(event: MotionEvent): Boolean { + if (childCount == 0) return false + val child = getChildAt(0) + val hitRect = Rect() + child.getHitRect(hitRect) + return if (hitRect.contains(event.x.toInt(), event.y.toInt())) { + super.onTouchEvent(event) + } else { + false + } + } + }.apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT ) - true } - anchorView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) = Unit - - override fun onViewDetachedFromWindow(v: View) { - if (rootContent.viewTreeObserver.isAlive) { - rootContent.viewTreeObserver.removeOnPreDrawListener(positionUpdater) - } - rootContent.findViewWithTag(DOWNLOAD_BUTTON_TAG)?.let { rootContent.removeView(it) } - v.removeOnAttachStateChangeListener(this) - } - }) - - rootContent.addView(button) - rootContent.viewTreeObserver.addOnPreDrawListener(positionUpdater) - rootContent.post { - updateOverlayButtonPosition( - activity = activity, - anchorView = anchorView, - button = button, - leftOffsetPx = leftOffsetPx, - topOffsetPx = topOffsetPx - ) - button.bringToFront() + parent.post { + if (parent.findViewWithTag(DOWNLOAD_BUTTON_TAG) != null) return@post + overlayWrapper.addView(button) + parent.addView(overlayWrapper) + overlayWrapper.bringToFront() } } @@ -437,23 +436,12 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") { BACKGROUND } - private fun updateOverlayButtonPosition( - activity: Activity, - anchorView: View, - button: View, - leftOffsetPx: Int, - topOffsetPx: Int - ) { - if (!anchorView.isAttachedToWindow || !button.isAttachedToWindow) return - - val rootContent = activity.findViewById(android.R.id.content) ?: return - val rootLocation = IntArray(2) - val anchorLocation = IntArray(2) - - rootContent.getLocationOnScreen(rootLocation) - anchorView.getLocationOnScreen(anchorLocation) - - button.x = (anchorLocation[0] - rootLocation[0] + leftOffsetPx).toFloat() - button.y = (anchorLocation[1] - rootLocation[1] + topOffsetPx).toFloat() + private fun Context.findActivity(): Activity? { + var current: Context? = this + while (current is ContextWrapper) { + if (current is Activity) return current + current = current.baseContext + } + return current as? Activity } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/FirstCreatedUsername.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/FirstCreatedUsername.kt new file mode 100644 index 00000000..5f238031 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/FirstCreatedUsername.kt @@ -0,0 +1,224 @@ +package me.eternal.purrfectsnap.core.features.impl.experiments + +import android.view.View +import android.view.ViewGroup +import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent +import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.ui.getValdiContext +import me.eternal.purrfectsnap.core.ui.getValdiViewNode +import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull +import me.eternal.purrfectsnap.core.util.ktx.setObjectField +import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiViewNode + +class FirstCreatedUsername : Feature("FirstCreatedUsername") { + private val profileViewSuffixes = setOf( + "UnifiedPublicProfileView", + "UserProfileV2RootComponent", + "UnifiedProfileFlatlandProfileView", + "UnifiedProfileFlatlandProfileViewTopViewFrameLayout", + "ProfileFlatlandFriendSnapScoreIdentityPillDialogView" + ) + + private val usernameFieldNames = listOf( + "_username", + "username", + "_mutableUsername", + "mutableUsername", + "_publicUsername", + "publicUsername", + "usernameForSorting" + ) + + private val nestedUsernameContainers = listOf( + "_user", + "user", + "_userInfo", + "userInfo", + "_friend", + "friend", + "_identity", + "identity", + "_profile", + "profile" + ) + + override fun init() { + if (!context.config.experimental.nativeHooks.valdiHooks.showFirstCreatedUsername.get()) return + + context.event.subscribe(AddViewEvent::class) { event -> + if (profileViewSuffixes.none { event.viewClassName.endsWith(it) }) return@subscribe + event.view.post { + patchProfileView(event.view) + } + } + } + + private fun patchProfileView(view: View) { + val valdiHost = sequenceOf(view, (view as? ViewGroup)?.getChildAt(0)) + .filterNotNull() + .firstOrNull { it.getValdiContext() != null } ?: return + + val valdiContext = valdiHost.getValdiContext() ?: return + val primaryViewModel = valdiContext.viewModel ?: valdiContext.viewModelLegacy ?: return + val legacyViewModel = valdiContext.viewModelLegacy + + val userId = primaryViewModel.findUserId() + ?: legacyViewModel?.findUserId() + + val currentUsername = primaryViewModel.findUsername() + ?: legacyViewModel?.findUsername() + ?: userId?.let { context.database.getFriendInfo(it)?.mutableUsername } + ?: return + + val normalizedCurrentUsername = normalizeUsername(currentUsername) ?: return + val firstCreatedUsername = resolveFirstCreatedUsername(userId, normalizedCurrentUsername) ?: return + if (firstCreatedUsername == normalizedCurrentUsername) return + + val decoratedUsername = "$normalizedCurrentUsername ($firstCreatedUsername)" + + var updated = primaryViewModel.applyUsernameOverride(normalizedCurrentUsername, decoratedUsername) + if (legacyViewModel != null && legacyViewModel !== primaryViewModel) { + updated = legacyViewModel.applyUsernameOverride(normalizedCurrentUsername, decoratedUsername) || updated + } + + valdiContext.enqueueNextRenderCallback { + val rootNode = valdiHost.getValdiViewNode() ?: return@enqueueNextRenderCallback + if (rootNode.applyRenderedUsernameOverride(normalizedCurrentUsername, decoratedUsername) || updated) { + view.postInvalidate() + } + } + } + + private fun resolveFirstCreatedUsername(userId: String?, currentUsername: String): String? { + context.database.getFriendOriginalUsername(currentUsername) + ?.takeIf { it.isNotBlank() && it != currentUsername } + ?.let { return it } + + val friendInfo = userId?.let(context.database::getFriendInfo) + ?: context.database.getFriendInfoByUsername(currentUsername) + + return friendInfo + ?.firstCreatedUsername + ?.takeIf { it.isNotBlank() && it != currentUsername } + } + + private fun Any.findUserId(): String? { + return getObjectFieldOrNull("_userId").asSafeString() + ?.takeIf { it.isNotBlank() && it != "null" } + ?: getObjectFieldOrNull("userId").asSafeString() + ?.takeIf { it.isNotBlank() && it != "null" } + } + + private fun Any.findUsername(): String? { + findUsernameInObject(this)?.let { return it } + nestedUsernameContainers.forEach { fieldName -> + val nestedObject = getObjectFieldOrNull(fieldName) ?: return@forEach + findUsernameInObject(nestedObject)?.let { return it } + } + return null + } + + private fun findUsernameInObject(target: Any): String? { + usernameFieldNames.forEach { fieldName -> + val value = target.getObjectFieldOrNull(fieldName).asSafeString() ?: return@forEach + normalizeUsername(value)?.let { return it } + } + return null + } + + private fun Any.applyUsernameOverride(currentUsername: String, decoratedUsername: String): Boolean { + var changed = applyUsernameOverrideToObject(this, currentUsername, decoratedUsername) + nestedUsernameContainers.forEach { fieldName -> + val nestedObject = getObjectFieldOrNull(fieldName) ?: return@forEach + changed = applyUsernameOverrideToObject(nestedObject, currentUsername, decoratedUsername) || changed + } + return changed + } + + private fun applyUsernameOverrideToObject(target: Any, currentUsername: String, decoratedUsername: String): Boolean { + var changed = false + val firstCreatedUsername = decoratedUsername + .substringAfter("(", "") + .substringBeforeLast(")") + .takeIf { it.isNotBlank() } + usernameFieldNames.forEach { fieldName -> + val rawValue = target.getObjectFieldOrNull(fieldName).asSafeString() ?: return@forEach + if (normalizeUsername(rawValue) != currentUsername) return@forEach + + val updatedValue = appendOriginalUsername(rawValue, currentUsername, firstCreatedUsername, decoratedUsername) + if (updatedValue == rawValue) return@forEach + + runCatching { + target.setObjectField(fieldName, updatedValue) + changed = true + } + } + return changed + } + + private fun ValdiViewNode.applyRenderedUsernameOverride(currentUsername: String, decoratedUsername: String): Boolean { + var changed = false + val firstCreatedUsername = decoratedUsername + .substringAfter("(", "") + .substringBeforeLast(")") + .takeIf { it.isNotBlank() } + walk().forEach { node -> + val className = node.getClassName() + if (!className.endsWith("SnapTextView") && !className.endsWith("TextView")) return@forEach + + arrayOf("value", "text", "title").forEach { attributeName -> + val rawValue = node.getAttribute(attributeName).asSafeString() ?: return@forEach + if (normalizeUsername(rawValue) != currentUsername) return@forEach + + val updatedValue = appendOriginalUsername(rawValue, currentUsername, firstCreatedUsername, decoratedUsername) + if (updatedValue == rawValue) return@forEach + + runCatching { + node.setAttribute(attributeName, updatedValue) + changed = true + } + } + } + return changed + } + + private fun ValdiViewNode.walk(): Sequence = sequence { + yield(this@walk) + getChildren().forEach { child -> + yieldAll(child.walk()) + } + } + + private fun normalizeUsername(value: String?): String? { + val trimmed = value?.trim()?.takeIf { it.isNotBlank() && it != "null" } ?: return null + return trimmed.removePrefix("@").substringBefore(" (").trim().takeIf { it.isNotBlank() } + } + + private fun Any?.asSafeString(): String? { + return when (this) { + is String -> this + is CharSequence -> this.toString() + is Char -> this.toString() + else -> null + } + } + + private fun appendOriginalUsername( + rawValue: String, + currentUsername: String, + firstCreatedUsername: String?, + decoratedUsername: String + ): String { + if (firstCreatedUsername != null && rawValue.contains("($firstCreatedUsername)")) return rawValue + if (rawValue.contains("($currentUsername)") || rawValue.contains("($decoratedUsername)")) return rawValue + + val prefixedCurrentUsername = "@$currentUsername" + return when { + rawValue == prefixedCurrentUsername -> "@$decoratedUsername" + rawValue.startsWith(prefixedCurrentUsername) -> rawValue.replaceFirst(prefixedCurrentUsername, "@$decoratedUsername") + rawValue == currentUsername -> decoratedUsername + rawValue.startsWith(currentUsername) -> rawValue.replaceFirst(currentUsername, decoratedUsername) + else -> rawValue + } + } +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt new file mode 100644 index 00000000..85517573 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt @@ -0,0 +1,57 @@ +package me.eternal.purrfectsnap.core.features.impl.ui + +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent +import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.ui.children +import java.lang.ref.WeakReference + +/** + * Provides snap jump logic for Story Snap List Download batch downloads. + * Initializes when storySnapListDownload is enabled to enable programmatic navigation between snaps. + */ +class OperaStoryOverlay : Feature("OperaStoryOverlay") { + private val overlayState = OperaStoryOverlayState() + private var storyFrameLayout = WeakReference(null) + private lateinit var snapJump: OperaStorySnapJump + + override fun init() { + val storySnapListDownload = context.config.downloader.storySnapListDownload.get() + + if (!storySnapListDownload) return + + snapJump = OperaStorySnapJump(context, overlayState) { storyFrameLayout.get() } + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) { + val viewGroup = event.view as FrameLayout + + if (viewGroup.findViewWithTag("story_counter") != null || + event.parent.findViewWithTag("story_counter") != null) return@subscribe + + if (event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }) return@subscribe + + storyFrameLayout = WeakReference(viewGroup) + } + } + + onNextActivityCreate { + overlayState.setupDisplayStateHook( + context = context, + showCounter = false, + showSourceIndicator = false, + onSnapFullyDisplayed = { + if (snapJump.isJumping()) { + snapJump.onSnapFullyDisplayed(it) + } + }, + onClearState = { snapJump.removeJumpOverlay() } + ) + } + } + + fun requestJumpToSnap(targetIndex: Int, totalCountOverride: Int? = null): Boolean = + snapJump.requestJumpToSnap(targetIndex, totalCountOverride) +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt new file mode 100644 index 00000000..fcb28c68 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt @@ -0,0 +1,98 @@ +package me.eternal.purrfectsnap.core.features.impl.ui + +import me.eternal.purrfectsnap.core.ModContext +import me.eternal.purrfectsnap.core.util.ktx.getObjectField +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap +import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper +import me.eternal.purrfectsnap.core.util.hook.HookStage +import me.eternal.purrfectsnap.core.util.hook.hook +import java.util.ArrayList +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf + +/** + * Shared state for Opera Story overlay (snap jump for batch download). + * Provides story index, total count, and hook setup for display state changes. + */ +class OperaStoryOverlayState { + val counterState = mutableStateOf("") + val sourceState = mutableStateOf("") + val currentIndexState = mutableIntStateOf(-1) + val totalCountState = mutableIntStateOf(0) + + fun setupDisplayStateHook( + context: ModContext, + showCounter: Boolean, + showSourceIndicator: Boolean, + onSnapFullyDisplayed: ((Int) -> Unit)?, + onClearState: (() -> Unit)? = null + ) { + context.mappings.useMapper(OperaPageViewControllerMapper::class) { + arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> + classReference.get()?.hook( + methodName.get() ?: return@forEach, + HookStage.AFTER + ) { param -> + val viewState = (param.thisObject() as Any).getObjectField(viewStateField.get()!!).toString() + + if (viewState != "FULLY_DISPLAYED") { + return@hook + } + + val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*> + val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap + val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString() + + if (mediaParamMap.containsKey("MESSAGE_ID")) { + context.runOnUiThread { + counterState.value = "" + sourceState.value = "" + currentIndexState.intValue = -1 + totalCountState.intValue = 0 + onClearState?.invoke() + } + return@hook + } + + if (snapSource == "SINGLE_SNAP_STORY") { + context.runOnUiThread { + counterState.value = "" + sourceState.value = "" + currentIndexState.intValue = -1 + totalCountState.intValue = 0 + onClearState?.invoke() + } + return@hook + } + + val currentIndex = mediaParamMap["snap_index_in_story"]?.toString()?.toIntOrNull() + ?: mediaParamMap["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() + val totalCount = mediaParamMap["snap_story_length"]?.toString()?.toIntOrNull() + ?: mediaParamMap["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() + + var mediaOrigin = "" + if (showSourceIndicator) { + val snapRecord = mediaParamMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString() ?: "" + mediaOrigin = if (snapRecord.contains("mediaOrigins=")) { + if (snapRecord.contains("mediaOrigins=[CAMERA]")) "CAMERA" else "GALLERY" + } else "" + } + + context.runOnUiThread { + counterState.value = if (showCounter && currentIndex != null && totalCount != null && totalCount > 0) { + "${currentIndex + 1} / $totalCount" + } else "" + sourceState.value = mediaOrigin + currentIndexState.intValue = currentIndex ?: -1 + totalCountState.intValue = totalCount ?: 0 + + onSnapFullyDisplayed?.let { callback -> + if (currentIndex != null) callback(currentIndex) + } + } + } + } + } + } +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt new file mode 100644 index 00000000..eed81245 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt @@ -0,0 +1,194 @@ +package me.eternal.purrfectsnap.core.features.impl.ui + +import android.graphics.Color as AndroidColor +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import kotlin.math.abs + +/** + * Handles snap jump logic: tap simulation, overlay, and navigation to target snap index. + */ +class OperaStorySnapJump( + private val context: me.eternal.purrfectsnap.core.ModContext, + private val overlayState: OperaStoryOverlayState, + private val storyFrameLayout: () -> ViewGroup? +) { + private val mainHandler = Handler(Looper.getMainLooper()) + + @Volatile private var isJumping = false + @Volatile private var jumpTargetIndex = -1 + private var jumpGeneration = 0 + private var retryRunnable: Runnable? = null + private var nextTapRunnable: Runnable? = null + private var lastHandledIndex = -1 + + fun simulateTap(forward: Boolean) { + val activity = context.mainActivity ?: return + val decorView = activity.window.decorView + val x = if (forward) decorView.width * 0.88f else decorView.width * 0.12f + val y = decorView.height * 0.5f + val downTime = SystemClock.uptimeMillis() + val tapDurationMs = 50L + + val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0) + decorView.dispatchTouchEvent(downEvent) + downEvent.recycle() + + val upEvent = MotionEvent.obtain(downTime, downTime + tapDurationMs, MotionEvent.ACTION_UP, x, y, 0) + decorView.dispatchTouchEvent(upEvent) + upEvent.recycle() + } + + private fun navigateStory(forward: Boolean) { + if (storyFrameLayout()?.isAttachedToWindow != true) return + simulateTap(forward) + } + + fun showJumpOverlay() { + val frameLayout = storyFrameLayout() ?: return + frameLayout.findViewWithTag("jump_overlay")?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + frameLayout.addView(View(frameLayout.context).apply { + tag = "jump_overlay" + setBackgroundColor(AndroidColor.BLACK) + isClickable = false + isFocusable = false + setOnTouchListener { _, _ -> false } + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + }, 0) + } + + private fun cancelPendingRetry() { + retryRunnable?.let { mainHandler.removeCallbacks(it) } + retryRunnable = null + } + + private fun cancelPendingNextTap() { + nextTapRunnable?.let { mainHandler.removeCallbacks(it) } + nextTapRunnable = null + } + + fun removeJumpOverlay() { + cancelPendingRetry() + cancelPendingNextTap() + isJumping = false + jumpTargetIndex = -1 + lastHandledIndex = -1 + mainHandler.postDelayed({ + val overlay = storyFrameLayout()?.findViewWithTag("jump_overlay") ?: return@postDelayed + overlay.animate() + .alpha(0f) + .setDuration(120) + .withEndAction { (overlay.parent as? ViewGroup)?.removeView(overlay) } + .start() + }, 150) + } + + private fun dispatchTapAndWaitForChange(fromIndex: Int, gen: Int, retryCount: Int = 0) { + if (!isJumping || jumpTargetIndex < 0 || gen != jumpGeneration) return + if (storyFrameLayout()?.isAttachedToWindow != true) { + removeJumpOverlay() + return + } + + val forward = jumpTargetIndex > fromIndex + simulateTap(forward) + + val remainingDistance = abs(jumpTargetIndex - fromIndex) + val maxRetries = maxOf(36, remainingDistance * 6) + val retryDelayMs = when { + retryCount < 5 -> 180L + retryCount < 12 -> 280L + else -> 400L + } + + val retry = Runnable { + if (!isJumping || gen != jumpGeneration) return@Runnable + val currentIdx = overlayState.currentIndexState.intValue + if (currentIdx == fromIndex) { + if (retryCount >= maxRetries) { + removeJumpOverlay() + } else { + dispatchTapAndWaitForChange(fromIndex, gen, retryCount + 1) + } + } + } + retryRunnable = retry + mainHandler.postDelayed(retry, retryDelayMs) + } + + fun onSnapFullyDisplayed(currentIndex: Int) { + if (!isJumping || jumpTargetIndex < 0) return + if (currentIndex == lastHandledIndex) return + + cancelPendingRetry() + cancelPendingNextTap() + + if (currentIndex == jumpTargetIndex) { + removeJumpOverlay() + return + } + + lastHandledIndex = currentIndex + val gen = jumpGeneration + val tapRunnable = Runnable { + nextTapRunnable = null + if (isJumping && gen == jumpGeneration && overlayState.currentIndexState.intValue == currentIndex) { + dispatchTapAndWaitForChange(currentIndex, gen) + } + } + nextTapRunnable = tapRunnable + mainHandler.postDelayed(tapRunnable, 45L) + } + + fun requestJumpToSnap(targetIndex: Int, totalCountOverride: Int? = null): Boolean { + if (storyFrameLayout()?.isAttachedToWindow != true) return false + val totalCount = totalCountOverride ?: overlayState.totalCountState.intValue + if (totalCount <= 1) return false + if (targetIndex < 0 || targetIndex >= totalCount) return false + mainHandler.post { jumpToSnap(targetIndex) } + return true + } + + fun jumpToSnap(targetIndex: Int) { + val current = overlayState.currentIndexState.intValue + if (current < 0 || targetIndex == current) return + + if (isJumping) { + cancelPendingRetry() + cancelPendingNextTap() + isJumping = false + jumpTargetIndex = -1 + } + + if (abs(targetIndex - current) == 1) { + navigateStory(targetIndex > current) + return + } + + jumpGeneration++ + jumpTargetIndex = targetIndex + lastHandledIndex = -1 + isJumping = true + + showJumpOverlay() + + val gen = jumpGeneration + mainHandler.postDelayed({ + if (gen == jumpGeneration && isJumping) { + dispatchTapAndWaitForChange(current, gen) + } + }, 50L) + } + + fun isJumping(): Boolean = isJumping +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt index 9a9373d9..e84f59d3 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -4,9 +4,7 @@ import android.graphics.BitmapFactory import android.view.Gravity import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout import android.widget.LinearLayout -import android.widget.ScrollView import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -63,7 +61,6 @@ import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu -import me.eternal.purrfectsnap.core.ui.randomTag import me.eternal.purrfectsnap.core.ui.triggerRootCloseTouchEvent import me.eternal.purrfectsnap.core.util.ktx.isDarkTheme import me.eternal.purrfectsnap.core.wrapper.impl.sanitizeForLayout @@ -524,7 +521,6 @@ class FriendFeedInfoMenu : AbstractMenu() { } } - private val recyclerViewTag = randomTag() private val messaging by lazy { context.feature(Messaging::class)} override fun onViewAdded(event: AddViewEvent) { @@ -533,46 +529,23 @@ class FriendFeedInfoMenu : AbstractMenu() { return constraintLayout.children().firstOrNull { it.javaClass.name.endsWith("AvatarView") } != null } - if (event.parent is FrameLayout && messaging.lastFocusedConversationType == 1 && event.view.javaClass.name.endsWith("RecyclerView")) { - event.view.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - if (event.view.tag == recyclerViewTag || !hasAvatarHeader(event.view as ViewGroup)) return@addOnLayoutChangeListener - event.view.tag = recyclerViewTag - - // remove recycler view - event.parent.removeView(event.view) - - val newLayout = LinearLayout(event.view.context).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.BOTTOM - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - addView(event.view) - } - - newLayout.addView(ScrollView(newLayout.context).apply { - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - weight = 1f; - setMargins(0, 100, 0, 0) - } - - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - injectIntoActionSheetItems(newLayout) { - it.layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - setMargins(0, 5, 0, 5) - } - addView(it) - } - }) - }, 0) - - event.parent.addView(newLayout) + if (messaging.lastFocusedConversationType == 1 && + event.viewClassName.endsWith("ConstraintLayout") && + event.parent.javaClass.name.endsWith("RecyclerView") + ) { + val actionSheetItemsContainerLayout = LinearLayout(event.view.context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) } + + injectIntoActionSheetItems(actionSheetItemsContainerLayout) { + actionSheetItemsContainerLayout.addView(it, 0) + } + + (event.view as? ViewGroup)?.addView(actionSheetItemsContainerLayout, 0) } if (event.parent is LinearLayout && event.viewClassName.endsWith("SnapCardView") && hasAvatarHeader(event.parent)) { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt index 64d19f1f..ff537bd4 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt @@ -104,10 +104,9 @@ class OperaContextActionMenu : AbstractMenu() { val playableStorySnapRecord = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString() val sentTimestamp = playableStorySnapRecord?.substringAfter("timestamp=") ?.substringBefore(",")?.toLongOrNull() - ?: paramMap["MESSAGE_ID"]?.toString()?.let { messageId -> + ?: mediaDownloader.resolveCurrentSnapMessageContext()?.clientMessageId?.let { messageId -> context.database.getConversationMessageFromId( - messageId.substring(messageId.lastIndexOf(":") + 1) - .toLong() + messageId )?.creationTimestamp } ?: paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull() diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaViewerIcons.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaViewerIcons.kt index e1614e10..44b029dc 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaViewerIcons.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaViewerIcons.kt @@ -6,41 +6,220 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.RemoveRedEye import androidx.compose.material.icons.outlined.Download import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.eternal.purrfectsnap.common.ui.createComposeView import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent +import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader +import me.eternal.purrfectsnap.core.features.impl.downloader.OperaViewerMessageContext import me.eternal.purrfectsnap.core.features.impl.messaging.AutoMarkAsRead import me.eternal.purrfectsnap.core.ui.children import me.eternal.purrfectsnap.core.ui.iterateParent import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu +import me.eternal.purrfectsnap.core.ui.randomTag import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent +import me.eternal.purrfectsnap.core.util.hook.HookStage +import me.eternal.purrfectsnap.core.util.hook.hook +import me.eternal.purrfectsnap.core.util.ktx.getObjectField import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress +import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper class OperaViewerIcons : AbstractMenu() { private val actionMenuIconSize by lazy { context.userInterface.dpToPx(32) } private val actionMenuIconMargin by lazy { context.userInterface.dpToPx(5) } private val actionMenuIconMarginTop by lazy { context.userInterface.dpToPx(10) } + private val injectedParentTag = randomTag() + private val viewerVisibleState = mutableStateOf(false) + private val viewerMessageContextState = mutableStateOf(null) + private val inlineMarkButtonVisibleState = mutableStateOf(false) + private var overlayRegistered = false + private var hooksInitialized = false + + override fun init() { + if (hooksInitialized) return + hooksInitialized = true + + registerOverlayFallback() + + context.event.subscribe(OnSnapInteractionEvent::class) { + viewerMessageContextState.value = context.feature(MediaDownloader::class).resolveCurrentSnapMessageContext() + } + + context.mappings.useMapper(OperaPageViewControllerMapper::class) { + arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> + classReference.get()?.hook( + methodName.get() ?: return@forEach, + HookStage.AFTER + ) { param -> + val viewState = param.thisObject().getObjectField(viewStateField.get()!!).toString() + val isVisible = viewState == "FULLY_DISPLAYED" + viewerVisibleState.value = isVisible + + if (!isVisible) { + viewerMessageContextState.value = null + inlineMarkButtonVisibleState.value = false + return@hook + } + + viewerMessageContextState.value = context.feature(MediaDownloader::class).resolveCurrentSnapMessageContext() + } + } + } + } + + private fun registerOverlayFallback() { + if (overlayRegistered) return + overlayRegistered = true + + context.inAppOverlay.addCustomComposable { + val messageContext = viewerMessageContextState.value + if ( + !context.config.messaging.markSnapAsSeenButton.get() || + !viewerVisibleState.value || + inlineMarkButtonVisibleState.value || + messageContext == null + ) return@addCustomComposable + + Box( + modifier = Modifier + .fillMaxSize() + .padding(end = 18.dp, bottom = 118.dp), + contentAlignment = Alignment.BottomEnd + ) { + Surface( + modifier = Modifier + .size(52.dp) + .clickable { + context.coroutineScope.launch { + markCurrentSnapAsSeen(parent = null) + } + }, + shape = CircleShape, + color = Color.Black.copy(alpha = 0.55f) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.RemoveRedEye, + tint = Color.White, + contentDescription = null + ) + } + } + } + } + } + + private fun Class<*>?.hasNameSuffixInHierarchy(suffix: String): Boolean { + var current = this + while (current != null) { + if (current.name.endsWith(suffix)) return true + current = current.superclass + } + return false + } + + private fun shouldInjectIntoViewer(event: AddViewEvent): Boolean { + if (event.view !is FrameLayout) return false + if (!event.parent.javaClass.hasNameSuffixInHierarchy("OpenLayout")) return false + + val viewGroup = event.view as? ViewGroup ?: return false + if (viewGroup.getTag(injectedParentTag) != null) return false + + val hasOnlyImageChildren = viewGroup.childCount > 0 && viewGroup.children().all { it is ImageView } + val hasMaskFrameSibling = event.parent.children().any { + it.javaClass.hasNameSuffixInHierarchy("ScalableCircleMaskFrameLayout") + } + + return hasOnlyImageChildren || hasMaskFrameSibling + } + + private fun resolveCurrentMessageContext(mediaDownloader: MediaDownloader): OperaViewerMessageContext? { + return mediaDownloader.resolveCurrentSnapMessageContext()?.also { + viewerMessageContextState.value = it + } + } + + private fun syncInlineMarkButtonVisibility(view: View, mediaDownloader: MediaDownloader) { + val isVisible = resolveCurrentMessageContext(mediaDownloader) != null + view.visibility = if (isVisible) View.VISIBLE else View.GONE + inlineMarkButtonVisibleState.value = isVisible + } + + private suspend fun markCurrentSnapAsSeen(parent: ViewGroup?) { + val messageContext = resolveCurrentMessageContext(context.feature(MediaDownloader::class)) ?: return + val result = context.feature(AutoMarkAsRead::class).markSnapAsSeen( + messageContext.conversationId, + messageContext.clientMessageId + ) + + if (result == "DUPLICATEREQUEST" || result == null) { + if (context.config.messaging.skipWhenMarkingAsSeen.get()) { + withContext(Dispatchers.Main) { + if (parent != null) { + parent.iterateParent { + it.triggerCloseTouchEvent() + false + } + } else { + context.mainActivity + ?.findViewById(android.R.id.content) + ?.triggerCloseTouchEvent() + } + } + } + } + + if (result == "DUPLICATEREQUEST") return + if (result == null) { + context.inAppOverlay.showStatusToast( + Icons.Default.Info, + context.translation["mark_as_seen.seen_toast"], + durationMs = 800 + ) + } else { + context.inAppOverlay.showStatusToast( + Icons.Default.Info, + "Failed to mark as seen: $result", + ) + } + } override fun onViewAdded(event: AddViewEvent) { - if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) { - val viewGroup = event.view as? ViewGroup ?: return - if ( - viewGroup.childCount == 0 || - viewGroup.children().any { it !is ImageView } || - event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") } - ) return - inject(viewGroup) - } + if (!shouldInjectIntoViewer(event)) return + val viewGroup = event.view as? ViewGroup ?: return + viewGroup.setTag(injectedParentTag, true) + viewerVisibleState.value = true + viewGroup.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + viewerVisibleState.value = true + } + + override fun onViewDetachedFromWindow(v: View) { + viewerVisibleState.value = false + inlineMarkButtonVisibleState.value = false + } + }) + inject(viewGroup) } private fun inject(parent: ViewGroup) { @@ -97,14 +276,6 @@ class OperaViewerIcons : AbstractMenu() { } if (context.config.messaging.markSnapAsSeenButton.get()) { - fun getMessageId(): Pair? { - return mediaDownloader.lastSeenMapParams?.get("MESSAGE_ID") - ?.toString() - ?.split(":") - ?.takeIf { it.size == 3 } - ?.let { return it[0] to it[2] } - } - parent.addView(createComposeView(parent.context) { Icon( imageVector = Icons.Default.RemoveRedEye, @@ -113,48 +284,23 @@ class OperaViewerIcons : AbstractMenu() { ) }.apply { setOnClickListener { - this@OperaViewerIcons.context.apply { - coroutineScope.launch { - val (conversationId, clientMessageId) = getMessageId() ?: return@launch - val result = feature(AutoMarkAsRead::class).markSnapAsSeen(conversationId, clientMessageId.toLong()) - - if (result == "DUPLICATEREQUEST" || result == null) { - if (config.messaging.skipWhenMarkingAsSeen.get()) { - withContext(Dispatchers.Main) { - parent.iterateParent { - it.triggerCloseTouchEvent() - false - } - } - } - } - - if (result == "DUPLICATEREQUEST") return@launch - if (result == null) { - inAppOverlay.showStatusToast( - Icons.Default.Info, - translation["mark_as_seen.seen_toast"], - durationMs = 800 - ) - } else { - inAppOverlay.showStatusToast( - Icons.Default.Info, - "Failed to mark as seen: $result", - ) - } - } + this@OperaViewerIcons.context.coroutineScope.launch { + markCurrentSnapAsSeen(parent) } } addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { v.visibility = View.GONE + inlineMarkButtonVisibleState.value = false this@OperaViewerIcons.context.coroutineScope.launch(Dispatchers.Main) { delay(250) - v.visibility = if (getMessageId() != null) View.VISIBLE else View.GONE + syncInlineMarkButtonVisibility(v, mediaDownloader) } } - override fun onViewDetachedFromWindow(v: View) {} + override fun onViewDetachedFromWindow(v: View) { + inlineMarkButtonVisibleState.value = false + } }) layoutParams = FrameLayout.LayoutParams( @@ -169,4 +315,4 @@ class OperaViewerIcons : AbstractMenu() { }) } } -} \ No newline at end of file +}