fix multiple issues

This commit is contained in:
ΞTΞRNAL
2026-03-10 18:43:57 +05:30
parent 05a383c823
commit 2b0c6cae36
4 changed files with 194 additions and 47 deletions

View File

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

View File

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

View File

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