From 7f9b911f0b4f240263a9c6cbc8df20e88c6b1535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=9ET=CE=9ERNAL?= Date: Mon, 9 Mar 2026 20:05:28 +0530 Subject: [PATCH 1/5] multiple fixes & improvements --- .../download/DownloadProcessor.kt | 163 ++++++++------ .../core/features/FeatureManager.kt | 1 + .../features/impl/downloader/CallRecorder.kt | 35 +-- .../downloader/ProfilePictureDownloader.kt | 112 +++++----- .../impl/experiments/FirstCreatedUsername.kt | 199 ++++++++++++++++++ 5 files changed, 375 insertions(+), 135 deletions(-) create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/FirstCreatedUsername.kt 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..1ada3859 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt @@ -61,6 +61,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") @@ -120,76 +124,36 @@ class DownloadProcessor ( val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension 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 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)!! - } - } else { - outputFolder + val saveResult = if (configuredFolder.isBlank()) { + saveToSystemDefault(fileName, fileType, inputFile, metadata)?.let { GallerySaveResult(it) } + } 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)?.let { + GallerySaveResult(it) } - } + } ?: throw Exception("Failed to save media (no output uri)") - // 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 - } + pendingTask.task.extra = saveResult.uri.toString() + pendingTask.success() - 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,6 +169,83 @@ 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 saveToSystemDefault( fileName: String, fileType: FileType, 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..fb567a53 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 @@ -142,6 +142,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/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..864de42f --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/FirstCreatedUsername.kt @@ -0,0 +1,199 @@ +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")?.toString()?.takeIf { it.isNotBlank() && it != "null" } + ?: getObjectFieldOrNull("userId")?.toString()?.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)?.toString() ?: 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 + usernameFieldNames.forEach { fieldName -> + val rawValue = target.getObjectFieldOrNull(fieldName)?.toString() ?: return@forEach + if (normalizeUsername(rawValue) != currentUsername) return@forEach + + val updatedValue = appendOriginalUsername(rawValue, currentUsername, 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 + 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)?.toString() ?: return@forEach + if (normalizeUsername(rawValue) != currentUsername) return@forEach + + val updatedValue = appendOriginalUsername(rawValue, currentUsername, 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 appendOriginalUsername(rawValue: String, currentUsername: String, decoratedUsername: String): String { + 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 + } + } +} From 5ece2ec408894fb86d976b6a8fa7cbbd6505bb9e Mon Sep 17 00:00:00 2001 From: particle-box Date: Tue, 10 Mar 2026 01:43:29 +0530 Subject: [PATCH 2/5] fix: mark snaps as seen button for newer snap versions --- ...otlin-compiler-17792999147329205525.salive | 0 .../impl/downloader/MediaDownloader.kt | 98 ++++++- .../ui/menu/impl/OperaContextActionMenu.kt | 5 +- .../core/ui/menu/impl/OperaViewerIcons.kt | 246 ++++++++++++++---- 4 files changed, 289 insertions(+), 60 deletions(-) create mode 100644 .kotlin/sessions/kotlin-compiler-17792999147329205525.salive diff --git a/.kotlin/sessions/kotlin-compiler-17792999147329205525.salive b/.kotlin/sessions/kotlin-compiler-17792999147329205525.salive new file mode 100644 index 00000000..e69de29b 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..d376f298 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 @@ -15,6 +15,7 @@ import androidx.compose.material.icons.outlined.* import kotlinx.coroutines.launch 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.* @@ -66,6 +67,11 @@ 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 @@ -195,6 +201,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 +356,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 +602,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") 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 +} From 07a8d3041e4de763c4d7ecb6cf02380561001cc5 Mon Sep 17 00:00:00 2001 From: RSR/ <52901789+RSR1337@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:00:20 +0400 Subject: [PATCH 3/5] feat: Download story snaps , Fix Translation --- .../ui/manager/pages/TasksRootSection.kt | 7 +- common/src/main/assets/lang/ar_AE.json | 11 +- common/src/main/assets/lang/bn.json | 1 + common/src/main/assets/lang/da.json | 1 + common/src/main/assets/lang/de_DE.json | 1 + common/src/main/assets/lang/en_UK.json | 1 + common/src/main/assets/lang/en_US.json | 15 +- common/src/main/assets/lang/es_ES.json | 1 + common/src/main/assets/lang/fi.json | 1 + common/src/main/assets/lang/fr_FR.json | 1 + common/src/main/assets/lang/hi_IN.json | 1 + common/src/main/assets/lang/hu_HU.json | 1 + common/src/main/assets/lang/id.json | 1 + common/src/main/assets/lang/it_IT.json | 1 + common/src/main/assets/lang/ja_JP.json | 1 + common/src/main/assets/lang/ko_KR.json | 1 + common/src/main/assets/lang/ku_KU.json | 1 + common/src/main/assets/lang/lv_LV.json | 1 + common/src/main/assets/lang/nb_NO.json | 1 + common/src/main/assets/lang/nl.json | 1 + common/src/main/assets/lang/pl.json | 1 + common/src/main/assets/lang/pt.json | 1 + common/src/main/assets/lang/ro.json | 1 + common/src/main/assets/lang/ru.json | 1 + common/src/main/assets/lang/sl_SI.json | 1 + common/src/main/assets/lang/sv.json | 1 + common/src/main/assets/lang/tr_TR.json | 1 + common/src/main/assets/lang/uk_UA.json | 1 + .../src/main/assets/lang/zh_SIMPLIFIED.json | 1 + .../common/config/impl/DownloaderConfig.kt | 1 + .../core/features/FeatureManager.kt | 3 +- .../impl/downloader/MediaDownloader.kt | 259 +++++++++++++++++- .../features/impl/ui/OperaStoryOverlay.kt | 57 ++++ .../impl/ui/OperaStoryOverlayState.kt | 98 +++++++ .../features/impl/ui/OperaStorySnapJump.kt | 194 +++++++++++++ 35 files changed, 664 insertions(+), 7 deletions(-) create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt 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 90f5567e..534f05de 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 @@ -195,6 +195,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 @@ -301,12 +302,12 @@ class TasksRootSection : Routes.Route() { ) Column { Text( - text = context.translation["delete_files_option"], + text = tasksTranslation["delete_files_option"], color = Color.White, fontWeight = FontWeight.SemiBold ) Text( - text = context.translation["delete_files_option_hint"] ?: "Also remove downloaded files", + text = tasksTranslation.getOrNull("delete_files_option_hint") ?: "Also remove downloaded files from device", color = PurrfectPalette.textSecondary, style = MaterialTheme.typography.bodySmall ) @@ -433,6 +434,7 @@ class TasksRootSection : Routes.Route() { message = messageText, showDeleteFiles = isSelection, deleteFilesChecked = alsoDeleteFiles, + tasksTranslation = translation, onToggleDeleteFiles = { alsoDeleteFiles = it }, onConfirm = { showConfirmDialog = false @@ -1049,6 +1051,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 b5b7e8db..b845aa18 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -309,6 +309,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": "إزالة جميع المهام؟" }, @@ -3012,7 +3013,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 4531d417..4b70ffbb 100644 --- a/common/src/main/assets/lang/bn.json +++ b/common/src/main/assets/lang/bn.json @@ -309,6 +309,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 77816383..b9d236f7 100644 --- a/common/src/main/assets/lang/da.json +++ b/common/src/main/assets/lang/da.json @@ -309,6 +309,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 04a6890f..94b9c233 100644 --- a/common/src/main/assets/lang/de_DE.json +++ b/common/src/main/assets/lang/de_DE.json @@ -309,6 +309,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 ef346c7e..8c809c75 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -309,6 +309,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 9df764a5..d3707ae0 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -309,6 +309,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?" }, @@ -993,6 +994,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" @@ -3011,7 +3016,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 1f2656c2..dd90fccc 100644 --- a/common/src/main/assets/lang/es_ES.json +++ b/common/src/main/assets/lang/es_ES.json @@ -309,6 +309,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 2f05ea51..58824748 100644 --- a/common/src/main/assets/lang/fi.json +++ b/common/src/main/assets/lang/fi.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Haluatko varmasti poistaa valitut tehtävät?", "remove_all_tasks_title": "Haluatko varmasti 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 3837de07..f494c0e0 100644 --- a/common/src/main/assets/lang/fr_FR.json +++ b/common/src/main/assets/lang/fr_FR.json @@ -309,6 +309,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 f6b00f0b..3984a489 100644 --- a/common/src/main/assets/lang/hi_IN.json +++ b/common/src/main/assets/lang/hi_IN.json @@ -309,6 +309,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 1dd7c0b7..eea1854b 100644 --- a/common/src/main/assets/lang/hu_HU.json +++ b/common/src/main/assets/lang/hu_HU.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Biztosan el akarod távolítani a kiválasztott feladatokat?", "remove_all_tasks_title": "Biztosan el akarod távolítani az összes feladatot?", "delete_files_option": "Fájlok törlése is", + "delete_files_option_hint": "Letöltött fájlok eltávolítása az eszközről is", "remove_selected_tasks_confirm": "{count} feladat eltávolítása?", "remove_all_tasks_confirm": "Összes feladat eltávolítása?" }, diff --git a/common/src/main/assets/lang/id.json b/common/src/main/assets/lang/id.json index f6e842b6..2b233775 100644 --- a/common/src/main/assets/lang/id.json +++ b/common/src/main/assets/lang/id.json @@ -309,6 +309,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 0395bdf8..c233c6c9 100644 --- a/common/src/main/assets/lang/it_IT.json +++ b/common/src/main/assets/lang/it_IT.json @@ -309,6 +309,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 71b6d1b9..a480b592 100644 --- a/common/src/main/assets/lang/ja_JP.json +++ b/common/src/main/assets/lang/ja_JP.json @@ -309,6 +309,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 c9d6b42d..2efcbe7c 100644 --- a/common/src/main/assets/lang/ko_KR.json +++ b/common/src/main/assets/lang/ko_KR.json @@ -309,6 +309,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 6ba90a63..6209edd6 100644 --- a/common/src/main/assets/lang/ku_KU.json +++ b/common/src/main/assets/lang/ku_KU.json @@ -309,6 +309,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 ddcadb54..09e9badc 100644 --- a/common/src/main/assets/lang/lv_LV.json +++ b/common/src/main/assets/lang/lv_LV.json @@ -309,6 +309,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 68f7120b..21633064 100644 --- a/common/src/main/assets/lang/nb_NO.json +++ b/common/src/main/assets/lang/nb_NO.json @@ -309,6 +309,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 også nedlastede filer fra enheten", "remove_selected_tasks_confirm": "Fjern {count} oppgaver?", "remove_all_tasks_confirm": "Fjern alle oppgaver?" }, diff --git a/common/src/main/assets/lang/nl.json b/common/src/main/assets/lang/nl.json index 661086b1..5902b3d0 100644 --- a/common/src/main/assets/lang/nl.json +++ b/common/src/main/assets/lang/nl.json @@ -309,6 +309,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 7eacd0d6..7b34f9de 100644 --- a/common/src/main/assets/lang/pl.json +++ b/common/src/main/assets/lang/pl.json @@ -309,6 +309,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 10cec78b..52e0792f 100644 --- a/common/src/main/assets/lang/pt.json +++ b/common/src/main/assets/lang/pt.json @@ -309,6 +309,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": "Também excluir arquivos", + "delete_files_option_hint": "Também remover arquivos baixados do dispositivo", "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 a03904b8..19c725e2 100644 --- a/common/src/main/assets/lang/ro.json +++ b/common/src/main/assets/lang/ro.json @@ -309,6 +309,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ă și fișierele descărcate de pe dispozitiv", "remove_selected_tasks_confirm": "Elimini {count} sarcini?", "remove_all_tasks_confirm": "Elimini toate sarcinile?" }, diff --git a/common/src/main/assets/lang/ru.json b/common/src/main/assets/lang/ru.json index caacc02d..6e8ac161 100644 --- a/common/src/main/assets/lang/ru.json +++ b/common/src/main/assets/lang/ru.json @@ -309,6 +309,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 9735cb16..d5ddead9 100644 --- a/common/src/main/assets/lang/sl_SI.json +++ b/common/src/main/assets/lang/sl_SI.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Ali ste prepričani, da želite odstraniti izbrane naloge?", "remove_all_tasks_title": "Ali ste prepričani, da želite odstraniti vse naloge?", "delete_files_option": "Izbriši tudi datoteke", + "delete_files_option_hint": "Odstrani tudi prenesene datoteke iz naprave", "remove_selected_tasks_confirm": "Odstrani {count} nalog?", "remove_all_tasks_confirm": "Odstrani vse naloge?" }, diff --git a/common/src/main/assets/lang/sv.json b/common/src/main/assets/lang/sv.json index 3ff47dd9..77671d80 100644 --- a/common/src/main/assets/lang/sv.json +++ b/common/src/main/assets/lang/sv.json @@ -309,6 +309,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 32266130..78a9525c 100644 --- a/common/src/main/assets/lang/tr_TR.json +++ b/common/src/main/assets/lang/tr_TR.json @@ -309,6 +309,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": "Cihazdan indirilen dosyaları da kaldır", "remove_selected_tasks_confirm": "{count} görev kaldırılsın mı?", "remove_all_tasks_confirm": "Tüm görevler kaldırılsın mı?" }, diff --git a/common/src/main/assets/lang/uk_UA.json b/common/src/main/assets/lang/uk_UA.json index 948ace16..570ba397 100644 --- a/common/src/main/assets/lang/uk_UA.json +++ b/common/src/main/assets/lang/uk_UA.json @@ -309,6 +309,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 414e065a..6f9e5836 100644 --- a/common/src/main/assets/lang/zh_SIMPLIFIED.json +++ b/common/src/main/assets/lang/zh_SIMPLIFIED.json @@ -309,6 +309,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/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 81db8a99..5ce61f64 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(), 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 5a82c4e9..a58d2cd9 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,9 +10,37 @@ 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.FileType @@ -46,6 +74,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.mapper.impl.OperaPageViewControllerMapper import java.nio.file.Paths import java.util.UUID @@ -62,6 +91,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp 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") } @@ -152,14 +185,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() { @@ -536,6 +782,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/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 +} From 05a383c82367ab03a653ab13111a5442565f9d9c Mon Sep 17 00:00:00 2001 From: particle-box Date: Tue, 10 Mar 2026 13:34:32 +0530 Subject: [PATCH 4/5] fix: crash issues --- ...otlin-compiler-1771782128871869494.salive} | 0 .../eternal/purrfectsnap/core/PurrfectSnap.kt | 58 ++++++++++-------- .../impl/experiments/FirstCreatedUsername.kt | 41 ++++++++++--- .../core/ui/menu/impl/FriendFeedInfoMenu.kt | 59 +++++-------------- 4 files changed, 83 insertions(+), 75 deletions(-) rename .kotlin/sessions/{kotlin-compiler-17792999147329205525.salive => kotlin-compiler-1771782128871869494.salive} (100%) diff --git a/.kotlin/sessions/kotlin-compiler-17792999147329205525.salive b/.kotlin/sessions/kotlin-compiler-1771782128871869494.salive similarity index 100% rename from .kotlin/sessions/kotlin-compiler-17792999147329205525.salive rename to .kotlin/sessions/kotlin-compiler-1771782128871869494.salive 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/impl/experiments/FirstCreatedUsername.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/FirstCreatedUsername.kt index 864de42f..5f238031 100644 --- 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 @@ -103,8 +103,10 @@ class FirstCreatedUsername : Feature("FirstCreatedUsername") { } private fun Any.findUserId(): String? { - return getObjectFieldOrNull("_userId")?.toString()?.takeIf { it.isNotBlank() && it != "null" } - ?: getObjectFieldOrNull("userId")?.toString()?.takeIf { it.isNotBlank() && it != "null" } + return getObjectFieldOrNull("_userId").asSafeString() + ?.takeIf { it.isNotBlank() && it != "null" } + ?: getObjectFieldOrNull("userId").asSafeString() + ?.takeIf { it.isNotBlank() && it != "null" } } private fun Any.findUsername(): String? { @@ -118,7 +120,7 @@ class FirstCreatedUsername : Feature("FirstCreatedUsername") { private fun findUsernameInObject(target: Any): String? { usernameFieldNames.forEach { fieldName -> - val value = target.getObjectFieldOrNull(fieldName)?.toString() ?: return@forEach + val value = target.getObjectFieldOrNull(fieldName).asSafeString() ?: return@forEach normalizeUsername(value)?.let { return it } } return null @@ -135,11 +137,15 @@ class FirstCreatedUsername : Feature("FirstCreatedUsername") { 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)?.toString() ?: return@forEach + val rawValue = target.getObjectFieldOrNull(fieldName).asSafeString() ?: return@forEach if (normalizeUsername(rawValue) != currentUsername) return@forEach - val updatedValue = appendOriginalUsername(rawValue, currentUsername, decoratedUsername) + val updatedValue = appendOriginalUsername(rawValue, currentUsername, firstCreatedUsername, decoratedUsername) if (updatedValue == rawValue) return@forEach runCatching { @@ -152,15 +158,19 @@ class FirstCreatedUsername : Feature("FirstCreatedUsername") { 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)?.toString() ?: return@forEach + val rawValue = node.getAttribute(attributeName).asSafeString() ?: return@forEach if (normalizeUsername(rawValue) != currentUsername) return@forEach - val updatedValue = appendOriginalUsername(rawValue, currentUsername, decoratedUsername) + val updatedValue = appendOriginalUsername(rawValue, currentUsername, firstCreatedUsername, decoratedUsername) if (updatedValue == rawValue) return@forEach runCatching { @@ -184,7 +194,22 @@ class FirstCreatedUsername : Feature("FirstCreatedUsername") { return trimmed.removePrefix("@").substringBefore(" (").trim().takeIf { it.isNotBlank() } } - private fun appendOriginalUsername(rawValue: String, currentUsername: String, decoratedUsername: String): String { + 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" 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)) { From 2b0c6cae36f32b1664ae65f50f2066b812d8f238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=9ET=CE=9ERNAL?= Date: Tue, 10 Mar 2026 18:43:57 +0530 Subject: [PATCH 5/5] fix multiple issues --- ...kotlin-compiler-1771782128871869494.salive | 0 .../purrfectsnap/bridge/BridgeService.kt | 25 --- .../download/DownloadProcessor.kt | 179 ++++++++++++++++-- common/src/main/assets/lang/en_US.json | 37 +++- 4 files changed, 194 insertions(+), 47 deletions(-) delete mode 100644 .kotlin/sessions/kotlin-compiler-1771782128871869494.salive diff --git a/.kotlin/sessions/kotlin-compiler-1771782128871869494.salive b/.kotlin/sessions/kotlin-compiler-1771782128871869494.salive deleted file mode 100644 index e69de29b..00000000 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 1ada3859..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 @@ -122,10 +123,10 @@ 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() val saveResult = if (configuredFolder.isBlank()) { - saveToSystemDefault(fileName, fileType, inputFile, metadata)?.let { GallerySaveResult(it) } + saveToSystemDefault(fileName, fileType, inputFile, metadata) } else { runCatching { saveToConfiguredFolder( @@ -138,9 +139,7 @@ class DownloadProcessor ( ) }.onFailure { remoteSideContext.log.error("Failed to save to configured folder, falling back to system default", it) - }.getOrNull() ?: saveToSystemDefault(fileName, fileType, inputFile, metadata)?.let { - GallerySaveResult(it) - } + }.getOrNull() ?: saveToSystemDefault(fileName, fileType, inputFile, metadata) } ?: throw Exception("Failed to save media (no output uri)") pendingTask.task.extra = saveResult.uri.toString() @@ -246,15 +245,107 @@ class DownloadProcessor ( 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 @@ -269,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) } } @@ -296,7 +433,7 @@ class DownloadProcessor ( } ) } - Uri.fromFile(destFile) + GallerySaveResult(Uri.fromFile(destFile)) } } diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index b36aa26d..723126cb 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -390,7 +390,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",