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",