diff --git a/.gitignore b/.gitignore index 2ef5deab..01c384fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,60 @@ +# ===== Gradle ===== + +.gradle/ +build/ +**/build/ + +# ===== Local Config ===== + +local.properties + +# ===== Logs ===== + +*.log + +# ===== Android Studio / IntelliJ ===== + *.iml -.gradle -/local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml +.idea/ +.idea/caches/ +.idea/libraries/ +.idea/modules.xml +.idea/workspace.xml +.idea/navEditor.xml +.idea/assetWizardSettings.xml + +# ===== OS Files ===== + .DS_Store -/build -/captures -.externalNativeBuild -.cxx -local.properties + +# ===== Android Build Outputs ===== + +*.apk +*.aab +output-metadata.json +captures/ +.externalNativeBuild/ +.cxx/ + +# ===== Keystore (IMPORTANT: never commit) ===== + +*.jks +*.keystore + +# ===== Google Services (optional: keep if needed) ===== + +google-services.json + +# ===== Profiling ===== + +*.hprof + +# ===== Misc ===== + *.txt +/builderrors.md + +# ===== Exceptions ===== + !fastlane/ !fastlane/**/*.txt -/builderrors.md diff --git a/app/src/main/java/com/akslabs/cloudgallery/App.kt b/app/src/main/java/com/akslabs/cloudgallery/App.kt index dd406bd8..5d5fb0e1 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/App.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/App.kt @@ -24,6 +24,7 @@ class App : Application() { super.onCreate() Preferences.init(applicationContext) + Preferences.getOrCreateDeviceId() // Generate device ID on first launch DbHolder.create(applicationContext) WorkModule.create(applicationContext) diff --git a/app/src/main/java/com/akslabs/cloudgallery/api/BotApi.kt b/app/src/main/java/com/akslabs/cloudgallery/api/BotApi.kt index d15420a9..6acee940 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/api/BotApi.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/api/BotApi.kt @@ -84,12 +84,33 @@ object BotApi { caption: String? = null ): Pair?>?, Exception?> { return withContext(Dispatchers.IO) { - bot.sendDocument( - chatId = ChatId.fromId(channelId), - document = TelegramFile.ByFile(file), - caption = caption, - disableContentTypeDetection = false - ) + Log.d(TAG, "πŸ“€ sendFile: file=${file.name}, size=${file.length()}, channel=$channelId, captionLen=${caption?.length ?: 0}") + if (!file.exists()) { + Log.e(TAG, "❌ sendFile: File does not exist: ${file.absolutePath}") + return@withContext Pair(null, java.io.FileNotFoundException("File not found: ${file.absolutePath}")) + } + if (channelId == 0L) { + Log.e(TAG, "❌ sendFile: Invalid channel ID: $channelId") + return@withContext Pair(null, IllegalArgumentException("Invalid channel ID: $channelId")) + } + try { + val result = bot.sendDocument( + chatId = ChatId.fromId(channelId), + document = TelegramFile.ByFile(file), + caption = caption, + disableContentTypeDetection = false + ) + val (response, error) = result + if (error != null) { + Log.e(TAG, "❌ sendFile: API error", error) + } else { + Log.d(TAG, "πŸ“€ sendFile: Response code=${response?.code()}, isSuccessful=${response?.isSuccessful}") + } + result + } catch (e: Exception) { + Log.e(TAG, "❌ sendFile: Exception during upload", e) + Pair(null, e) + } } } @@ -112,88 +133,22 @@ object BotApi { } /** - * Scan Telegram channel/chat for all media files (documents and photos) - * Returns a list of discovered media files with their metadata + * Get file metadata from Telegram (for individual file size lookups). + * Returns the file size in bytes, or null if not available. */ - suspend fun scanChannelForMedia( - channelId: Long, - limit: Int = 100, - offsetMessageId: Long? = null - ): ChannelScanResult { + suspend fun getFileSize(fileId: String): Long? { return withContext(Dispatchers.IO) { try { - Log.d(TAG, "=== SCANNING CHANNEL FOR MEDIA ===") - Log.d(TAG, "Channel ID: $channelId, Limit: $limit, Offset: $offsetMessageId") - - val updates = bot.getUpdates( - offset = offsetMessageId, - limit = limit, - timeout = 30 - ) - - val mediaFiles = mutableListOf() - var lastMessageId: Long? = null - - if (updates.isSuccess) { - val updateList = updates.get() - Log.i(TAG, "Received ${updateList.size} updates from Telegram") - - updateList.forEach { update -> - update.message?.let { message -> - // Only process messages from the target channel - if (message.chat.id == channelId) { - lastMessageId = message.messageId.toLong() - - // Check for document attachments - message.document?.let { document -> - val mediaFile = DiscoveredMediaFile( - fileId = document.fileId, - fileName = document.fileName, - fileSize = document.fileSize?.toLong(), - mimeType = document.mimeType, - uploadDate = message.date * 1000L, // Convert to milliseconds - messageId = message.messageId.toInt(), - mediaType = MediaType.DOCUMENT - ) - mediaFiles.add(mediaFile) - Log.d(TAG, "Found document: ${document.fileName} (${document.fileId})") - } - - // Check for photo attachments - message.photo?.let { photos -> - // Get the largest photo size - val largestPhoto = photos.maxByOrNull { it.fileSize ?: 0 } - largestPhoto?.let { photo -> - val mediaFile = DiscoveredMediaFile( - fileId = photo.fileId, - fileName = "photo_${message.messageId}.jpg", - fileSize = photo.fileSize?.toLong(), - mimeType = "image/jpeg", - uploadDate = message.date * 1000L, - messageId = message.messageId.toInt(), - mediaType = MediaType.PHOTO - ) - mediaFiles.add(mediaFile) - Log.d(TAG, "Found photo: ${photo.fileId}") - } - } - } - } - } - - Log.i(TAG, "Scan complete: Found ${mediaFiles.size} media files") - ChannelScanResult.Success( - mediaFiles = mediaFiles, - hasMore = updateList.size == limit, - nextOffset = lastMessageId?.plus(1) - ) - } else { - Log.e(TAG, "Failed to get updates from Telegram") - ChannelScanResult.Error("Failed to fetch updates from Telegram") + val result = bot.getFile(fileId) + val (response, error) = result + if (error != null) { + Log.e(TAG, "Error getting file size for $fileId", error) + return@withContext null } + response?.body()?.result?.fileSize?.toLong() } catch (e: Exception) { - Log.e(TAG, "Exception during channel scan", e) - ChannelScanResult.Error("Exception during channel scan: ${e.message}") + Log.e(TAG, "Error getting file size for $fileId", e) + null } } } diff --git a/app/src/main/java/com/akslabs/cloudgallery/api/ChannelScanModels.kt b/app/src/main/java/com/akslabs/cloudgallery/api/ChannelScanModels.kt deleted file mode 100644 index e554d184..00000000 --- a/app/src/main/java/com/akslabs/cloudgallery/api/ChannelScanModels.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.akslabs.cloudgallery.api - -import android.os.Parcelable -import androidx.annotation.Keep -import kotlinx.parcelize.Parcelize - -/** - * Result of scanning a Telegram channel for media files - */ -sealed class ChannelScanResult { - data class Success( - val mediaFiles: List, - val hasMore: Boolean, - val nextOffset: Long? - ) : ChannelScanResult() - - data class Error(val message: String) : ChannelScanResult() -} - -/** - * Represents a media file discovered in a Telegram channel - */ -@Keep -@Parcelize -data class DiscoveredMediaFile( - val fileId: String, - val fileName: String?, - val fileSize: Long?, - val mimeType: String?, - val uploadDate: Long, - val messageId: Int, - val mediaType: MediaType -) : Parcelable { - - /** - * Get file extension from filename or mime type - */ - fun getFileExtension(): String? { - return fileName?.substringAfterLast('.', "")?.takeIf { it.isNotEmpty() } - ?: when (mimeType) { - "image/jpeg" -> "jpg" - "image/png" -> "png" - "image/gif" -> "gif" - "image/webp" -> "webp" - "image/bmp" -> "bmp" - "video/mp4" -> "mp4" - "video/avi" -> "avi" - "video/mov" -> "mov" - "video/mkv" -> "mkv" - else -> null - } - } - - /** - * Check if this is an image file - */ - fun isImage(): Boolean { - return mediaType == MediaType.PHOTO || - mimeType?.startsWith("image/") == true || - getFileExtension()?.lowercase() in listOf("jpg", "jpeg", "png", "gif", "webp", "bmp") - } - - /** - * Check if this is a video file - */ - fun isVideo(): Boolean { - return mimeType?.startsWith("video/") == true || - getFileExtension()?.lowercase() in listOf("mp4", "avi", "mov", "mkv", "webm", "3gp") - } -} - -/** - * Type of media discovered in the channel - */ -@Keep -enum class MediaType { - DOCUMENT, // Files sent as documents - PHOTO // Files sent as photos (compressed by Telegram) -} - -/** - * Progress information for channel scanning operation - */ -data class ScanProgress( - val currentBatch: Int, - val totalFilesFound: Int, - val isComplete: Boolean, - val errorMessage: String? = null -) - -/** - * Configuration for channel scanning - */ -data class ScanConfig( - val channelId: Long, - val batchSize: Int = 100, - val maxFiles: Int = 10000, // Prevent infinite scanning - val includePhotos: Boolean = true, - val includeDocuments: Boolean = true, - val includeVideos: Boolean = true -) diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/Preferences.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/Preferences.kt index e2c37926..7a0c836b 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/Preferences.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/Preferences.kt @@ -2,6 +2,7 @@ package com.akslabs.cloudgallery.data.localdb import android.content.Context import android.content.SharedPreferences +import android.os.Build import android.util.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -9,6 +10,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKeys +import java.util.UUID @Suppress("ktlint:standard:property-naming") object Preferences { @@ -17,6 +19,28 @@ object Preferences { const val botToken: String = "botToken" const val channelId: String = "channelId" + // device identity + const val deviceIdKey: String = "device_id" + const val deviceNameKey: String = "device_name" + + fun getOrCreateDeviceId(): String { + var id = getEncryptedString(deviceIdKey, "") + if (id.isBlank()) { + id = UUID.randomUUID().toString().take(8) + editEncrypted { putString(deviceIdKey, id) } + } + return id + } + + fun getDeviceName(): String { + val default = "${Build.MANUFACTURER} ${Build.MODEL}" + return getString(deviceNameKey, default) + } + + fun setDeviceName(name: String) { + edit { putString(deviceNameKey, name) } + } + // non-encrypted preferences const val startTabKey: String = "startTab" const val gridColumnCountKey: String = "gridColumnCount" diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/WhDatabase.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/WhDatabase.kt index 3bff4716..a5527c70 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/WhDatabase.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/WhDatabase.kt @@ -7,23 +7,31 @@ import androidx.room.RoomDatabase import com.akslabs.cloudgallery.data.localdb.dao.DeletedPhotoDao import com.akslabs.cloudgallery.data.localdb.dao.PhotoDao import com.akslabs.cloudgallery.data.localdb.dao.RemotePhotoDao +import com.akslabs.cloudgallery.data.localdb.dao.UploadQueueDao +import com.akslabs.cloudgallery.data.localdb.dao.SyncMetadataDao import com.akslabs.cloudgallery.data.localdb.entities.DeletedPhoto import com.akslabs.cloudgallery.data.localdb.entities.Photo import com.akslabs.cloudgallery.data.localdb.entities.RemotePhoto +import com.akslabs.cloudgallery.data.localdb.entities.UploadQueue +import com.akslabs.cloudgallery.data.localdb.entities.SyncMetadata import com.akslabs.cloudgallery.data.localdb.migration.Migration1to2_NullableRemoteId import com.akslabs.cloudgallery.data.localdb.migration.Migration2to3_RemotePhotoTable import com.akslabs.cloudgallery.data.localdb.migration.Migration3to4_EnhancedRemotePhoto import com.akslabs.cloudgallery.data.localdb.migration.Migration4to5_DeletedPhotos +import com.akslabs.cloudgallery.data.localdb.migration.Migration7to8_MultiDevice @Database( - entities = [Photo::class, RemotePhoto::class, DeletedPhoto::class], - version = 7, + entities = [Photo::class, RemotePhoto::class, DeletedPhoto::class, UploadQueue::class, SyncMetadata::class], + version = 8, exportSchema = false ) abstract class WhDatabase : RoomDatabase() { abstract fun photoDao(): PhotoDao abstract fun remotePhotoDao(): RemotePhotoDao + @Deprecated("Use RemotePhotoDao soft-delete instead. Kept for backward compatibility.") abstract fun deletedPhotoDao(): DeletedPhotoDao + abstract fun uploadQueueDao(): UploadQueueDao + abstract fun syncMetadataDao(): SyncMetadataDao companion object { private const val DATABASE_NAME = "wh_database" @@ -44,7 +52,8 @@ abstract class WhDatabase : RoomDatabase() { Migration3to4_EnhancedRemotePhoto(), Migration4to5_DeletedPhotos(), com.akslabs.cloudgallery.data.localdb.migration.Migration5to6_MessageId(), - com.akslabs.cloudgallery.data.localdb.migration.Migration6to7_UploadType() + com.akslabs.cloudgallery.data.localdb.migration.Migration6to7_UploadType(), + Migration7to8_MultiDevice() ) .fallbackToDestructiveMigration() .build() diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/backup/BackupFile.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/backup/BackupFile.kt index 8b2eeea3..101f2ffb 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/backup/BackupFile.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/backup/BackupFile.kt @@ -6,6 +6,10 @@ import com.akslabs.cloudgallery.data.localdb.entities.RemotePhoto @Keep data class BackupFile( + val version: Int = 2, + val deviceId: String = "", + val deviceName: String = "", + val createdAt: Long = System.currentTimeMillis(), val photos: List = emptyList(), val remotePhotos: List = emptyList(), ) diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/backup/BackupHelper.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/backup/BackupHelper.kt index 58dacbbb..15e03175 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/backup/BackupHelper.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/backup/BackupHelper.kt @@ -32,16 +32,25 @@ object BackupHelper { suspend fun exportDatabase(uri: Uri, context: Context) { try { + val deviceId = Preferences.getOrCreateDeviceId() + val deviceName = Preferences.getDeviceName() val photos = DbHolder.database.photoDao().getAll() - val remotePhotos = DbHolder.database.remotePhotoDao().getAll() - val backupFile = BackupFile(photos, remotePhotos) + val remotePhotos = DbHolder.database.remotePhotoDao().getAllIncludingDeleted() + val backupFile = BackupFile( + version = 2, + deviceId = deviceId, + deviceName = deviceName, + createdAt = System.currentTimeMillis(), + photos = photos, + remotePhotos = remotePhotos + ) // Handle both specific file URIs and directory (tree) URIs val targetUri = if (uri.toString().contains("tree")) { val directory = DocumentFile.fromTreeUri(context, uri) val dateFormat = SimpleDateFormat("yyyy-MM-dd_hh-mm-a", Locale.getDefault()) val timestamp = dateFormat.format(Date()) - val fileName = "CloudGallery_AutoBackup_$timestamp.json" + val fileName = "CloudGallery_Backup_${deviceId}_$timestamp.json" directory?.createFile(JSON_MIME, fileName)?.uri } else { uri @@ -63,14 +72,32 @@ object BackupHelper { } } + /** + * Import database backup with device-aware merge: + * - Same device: full import (photos + remotePhotos) + * - Different device: only merge remotePhotos (skip photos to avoid localId collisions) + */ suspend fun importDatabase(uri: Uri, context: Context) { try { context.contentResolver.openInputStream(uri)?.use { val backupFile = mapper.readValue(it.readBytes(), BackupFile::class.java) - DbHolder.database.photoDao().updatePhotos(*backupFile.photos.toTypedArray()) - DbHolder.database.remotePhotoDao().insertAll( - *backupFile.remotePhotos.toTypedArray() - ) + val currentDeviceId = Preferences.getOrCreateDeviceId() + val isSameDevice = backupFile.deviceId.isEmpty() || backupFile.deviceId == currentDeviceId + + if (isSameDevice) { + // Same device β€” full import + Log.i(TAG, "Importing from same device (${backupFile.deviceId}): full import") + DbHolder.database.photoDao().updatePhotos(*backupFile.photos.toTypedArray()) + DbHolder.database.remotePhotoDao().insertAll( + *backupFile.remotePhotos.toTypedArray() + ) + } else { + // Different device β€” only merge remotePhotos to avoid localId collisions + Log.i(TAG, "Importing from different device (${backupFile.deviceId} β†’ $currentDeviceId): remotePhotos only") + DbHolder.database.remotePhotoDao().insertAll( + *backupFile.remotePhotos.toTypedArray() + ) + } } context.toastFromMainThread(context.getString(R.string.import_successful)) } catch (e: Exception) { @@ -80,43 +107,49 @@ object BackupHelper { } /** - * Upload database backup to Telegram + * Upload database backup to Telegram with device metadata */ suspend fun uploadDatabaseToTelegram(context: Context): Result { return withContext(Dispatchers.IO) { try { Log.i(TAG, "=== UPLOADING DATABASE TO TELEGRAM ===") - // Get channel ID val channelId = Preferences.getEncryptedLong(Preferences.channelId, 0L) if (channelId == 0L) { return@withContext Result.failure(Exception("No Telegram channel configured")) } - // Create database backup + val deviceId = Preferences.getOrCreateDeviceId() + val deviceName = Preferences.getDeviceName() + val photos = DbHolder.database.photoDao().getAll() - val remotePhotos = DbHolder.database.remotePhotoDao().getAll() - val backupFile = BackupFile(photos, remotePhotos) + val remotePhotos = DbHolder.database.remotePhotoDao().getAllIncludingDeleted() + val backupFile = BackupFile( + version = 2, + deviceId = deviceId, + deviceName = deviceName, + createdAt = System.currentTimeMillis(), + photos = photos, + remotePhotos = remotePhotos + ) - Log.i(TAG, "Database backup created: ${photos.size} photos, ${remotePhotos.size} remote photos") + Log.i(TAG, "Database backup created: ${photos.size} photos, ${remotePhotos.size} remote photos (device: $deviceId)") - // Convert to JSON val backupJson = mapper.writeValueAsBytes(backupFile) - // Create filename with date val dateFormat = SimpleDateFormat("yyyy-MM-dd_hh-mm-a", Locale.getDefault()) val timestamp = dateFormat.format(Date()) - val fileName = "${DATABASE_BACKUP_PREFIX}_$timestamp.json" + val fileName = "${DATABASE_BACKUP_PREFIX}_${deviceId}_$timestamp.json" - // Create a named file in cache directory val tempFile = File(context.cacheDir, fileName) tempFile.writeBytes(backupJson) Log.i(TAG, "Created backup file: $fileName (${tempFile.length()} bytes)") - // Upload to Telegram + // Upload with device tag in caption + val caption = "#db_backup #device:$deviceId $deviceName" Log.i(TAG, "Uploading to Telegram channel: $channelId") - val uploadResult = BotApi.sendFile(tempFile, channelId) + val uploadResult = BotApi.sendFile(tempFile, channelId, caption) val (response, error) = uploadResult if (error != null) { @@ -128,7 +161,6 @@ object BackupHelper { if (document != null) { Log.i(TAG, "βœ… Database backup uploaded successfully: ${document.fileId}") - // Store backup info Preferences.edit { putString("last_database_backup_file_id", document.fileId) putString("last_database_backup_filename", fileName) @@ -137,9 +169,7 @@ object BackupHelper { putLong("last_database_backup_remote_photos", remotePhotos.size.toLong()) } - // Clean up temp file tempFile.delete() - Result.success("Database uploaded to Telegram: $fileName") } else { tempFile.delete() @@ -155,7 +185,7 @@ object BackupHelper { } /** - * Download and import database backup from Telegram + * Download and import database backup from Telegram with device-aware merge */ suspend fun downloadDatabaseFromTelegram(fileId: String, context: Context): Result { return withContext(Dispatchers.IO) { @@ -163,31 +193,36 @@ object BackupHelper { Log.i(TAG, "=== DOWNLOADING DATABASE FROM TELEGRAM ===") Log.i(TAG, "Downloading backup file: $fileId") - // Download file from Telegram val fileBytes = BotApi.getFile(fileId) if (fileBytes == null) { return@withContext Result.failure(Exception("Failed to download backup file")) } - // Parse JSON val backupFile = mapper.readValue(fileBytes, BackupFile::class.java) + val currentDeviceId = Preferences.getOrCreateDeviceId() + val isSameDevice = backupFile.deviceId.isEmpty() || backupFile.deviceId == currentDeviceId - Log.i(TAG, "Database backup downloaded: ${backupFile.photos.size} photos, ${backupFile.remotePhotos.size} remote photos") + Log.i(TAG, "Database backup downloaded: ${backupFile.photos.size} photos, ${backupFile.remotePhotos.size} remote photos (from device: ${backupFile.deviceId})") - // Import to database - DbHolder.database.photoDao().updatePhotos(*backupFile.photos.toTypedArray()) - DbHolder.database.remotePhotoDao().insertAll(*backupFile.remotePhotos.toTypedArray()) + if (isSameDevice) { + Log.i(TAG, "Same device import β€” full merge") + DbHolder.database.photoDao().updatePhotos(*backupFile.photos.toTypedArray()) + DbHolder.database.remotePhotoDao().insertAll(*backupFile.remotePhotos.toTypedArray()) + } else { + Log.i(TAG, "Cross-device import β€” remotePhotos only (skipping ${backupFile.photos.size} local photos)") + DbHolder.database.remotePhotoDao().insertAll(*backupFile.remotePhotos.toTypedArray()) + } Log.i(TAG, "βœ… Database imported successfully") - // Update preferences Preferences.edit { putLong("last_database_import_timestamp", System.currentTimeMillis()) putLong("last_database_import_photos", backupFile.photos.size.toLong()) putLong("last_database_import_remote_photos", backupFile.remotePhotos.size.toLong()) } - Result.success("Database imported: ${backupFile.photos.size} photos, ${backupFile.remotePhotos.size} remote photos") + val importType = if (isSameDevice) "full" else "remotePhotos only" + Result.success("Database imported ($importType): ${backupFile.photos.size} photos, ${backupFile.remotePhotos.size} remote photos") } catch (e: Exception) { Log.e(TAG, "Exception downloading database from Telegram", e) @@ -209,11 +244,9 @@ object BackupHelper { val lastBackupPhotos = Preferences.getLong("last_database_backup_photos", 0L).toInt() val lastBackupRemotePhotos = Preferences.getLong("last_database_backup_remote_photos", 0L).toInt() - // Get current database state efficiently if not provided val photosCount = currentPhotos ?: DbHolder.database.photoDao().getCount() val remotePhotosCount = currentRemotePhotos ?: DbHolder.database.remotePhotoDao().getCount() - // Check if backup exists and data hasn't changed val hasBackup = lastBackupTimestamp > 0 val dataUnchanged = (photosCount == lastBackupPhotos && remotePhotosCount == lastBackupRemotePhotos) @@ -229,25 +262,18 @@ object BackupHelper { } } - /** - * Check if daily backup is needed - */ fun shouldCreateDailyBackup(): Boolean { val lastBackup = Preferences.getLong("last_database_backup_timestamp", 0L) val oneDayAgo = System.currentTimeMillis() - (24 * 60 * 60 * 1000) return lastBackup < oneDayAgo } - /** - * Get backup statistics for UI display - */ suspend fun getBackupStats(): BackupStats { return withContext(Dispatchers.IO) { val lastBackupTime = Preferences.getLong("last_database_backup_timestamp", 0L) val lastBackupFilename = Preferences.getString("last_database_backup_filename", "") val lastImportTime = Preferences.getLong("last_database_import_timestamp", 0L) - // Use optimized count queries instead of loading all objects val currentPhotos = DbHolder.database.photoDao().getCount() val currentRemotePhotos = DbHolder.database.remotePhotoDao().getCount() diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/PhotoDao.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/PhotoDao.kt index 4abba934..c55a4058 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/PhotoDao.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/PhotoDao.kt @@ -72,11 +72,34 @@ interface PhotoDao { @Query("UPDATE photos SET remoteId = :remoteId WHERE pathUri = :pathUri") suspend fun updateRemoteIdForPath(pathUri: String, remoteId: String) + @Query("UPDATE photos SET remoteId = :remoteId WHERE localId = :localId") + suspend fun updateRemoteIdForLocalId(localId: String, remoteId: String) + @Query("SELECT localId FROM photos") suspend fun getAllLocalIds(): List @Query("SELECT localId, remoteId FROM photos WHERE remoteId IS NOT NULL") suspend fun getSyncedPhotoMap(): List + + // ── New queries for multi-device & dedup ── + + @Query("SELECT * FROM photos WHERE contentHash = :hash LIMIT 1") + suspend fun getByContentHash(hash: String): Photo? + + @Query("UPDATE photos SET uploadStatus = :status, lastUploadAttempt = :lastAttempt WHERE localId = :localId") + suspend fun updateUploadStatus(localId: String, status: String, lastAttempt: Long? = null) + + @Query("UPDATE photos SET contentHash = :hash WHERE localId = :localId") + suspend fun updateContentHash(localId: String, hash: String) + + @Query("SELECT * FROM photos WHERE contentHash IS NULL") + suspend fun getAllNeedingHash(): List + + @Query("SELECT * FROM photos WHERE uploadStatus IN ('NONE', 'FAILED') AND remoteId IS NULL") + suspend fun getAllPendingUpload(): List + + @Query("UPDATE photos SET deviceId = :deviceId WHERE localId = :localId") + suspend fun setDeviceId(localId: String, deviceId: String) } data class SyncedPhotoTuple( diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/RemotePhotoDao.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/RemotePhotoDao.kt index ca587815..520f1c12 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/RemotePhotoDao.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/RemotePhotoDao.kt @@ -14,22 +14,27 @@ import kotlinx.coroutines.flow.Flow @Keep @Dao interface RemotePhotoDao { - @Query("SELECT * FROM remote_photos ORDER BY uploadedAt DESC") + // Default queries now filter by ACTIVE status + @Query("SELECT * FROM remote_photos WHERE status = 'ACTIVE' ORDER BY uploadedAt DESC") fun getAllPagingSource(): PagingSource - @Query("SELECT * FROM remote_photos ORDER BY uploadedAt DESC") + @Query("SELECT * FROM remote_photos WHERE status = 'ACTIVE' ORDER BY uploadedAt DESC") fun getAllFlow(): Flow> - @Query("SELECT * FROM remote_photos") + @Query("SELECT * FROM remote_photos WHERE status = 'ACTIVE'") suspend fun getAll(): List - @Query("SELECT COUNT(*) FROM remote_photos") + // Unfiltered queries (for backup/migration purposes) + @Query("SELECT * FROM remote_photos") + suspend fun getAllIncludingDeleted(): List + + @Query("SELECT COUNT(*) FROM remote_photos WHERE status = 'ACTIVE'") fun getTotalCountFlow(): Flow - @Query("SELECT COUNT(*) FROM remote_photos") + @Query("SELECT COUNT(*) FROM remote_photos WHERE status = 'ACTIVE'") suspend fun getCount(): Int - @Query("SELECT SUM(fileSize) FROM remote_photos") + @Query("SELECT SUM(fileSize) FROM remote_photos WHERE status = 'ACTIVE'") fun getTotalSizeFlow(): Flow @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -53,12 +58,41 @@ interface RemotePhotoDao { @Query("UPDATE remote_photos SET thumbnailCached = :cached WHERE remoteId = :remoteId") suspend fun updateThumbnailCached(remoteId: String, cached: Boolean) - // For "delete backed up photos" feature - get photos that exist in cloud but can be deleted from device + // For "delete backed up photos" feature @Query( - "SELECT p.* FROM photos p INNER JOIN remote_photos rp ON p.remoteId = rp.remoteId WHERE p.remoteId IS NOT NULL" + "SELECT p.* FROM photos p INNER JOIN remote_photos rp ON p.remoteId = rp.remoteId WHERE p.remoteId IS NOT NULL AND rp.status = 'ACTIVE'" ) suspend fun getBackedUpPhotosOnDevice(): List - @Query("SELECT remoteId FROM remote_photos") + @Query("SELECT remoteId FROM remote_photos WHERE status = 'ACTIVE'") suspend fun getAllRemoteIds(): List + + // ── New queries for multi-device & dedup ── + + @Query("SELECT * FROM remote_photos WHERE contentHash = :hash AND status = 'ACTIVE' LIMIT 1") + suspend fun getByContentHash(hash: String): RemotePhoto? + + @Query("SELECT * FROM remote_photos WHERE status = 'ACTIVE' ORDER BY uploadedAt DESC") + fun getActivePagingSource(): PagingSource + + @Query("SELECT * FROM remote_photos WHERE status = 'DELETED' ORDER BY deletedAt DESC") + fun getDeletedPagingSource(): PagingSource + + @Query("SELECT * FROM remote_photos WHERE status = 'DELETED' ORDER BY deletedAt DESC") + fun getDeletedFlow(): Flow> + + @Query("SELECT COUNT(*) FROM remote_photos WHERE status = 'DELETED'") + fun getDeletedCountFlow(): Flow + + @Query("SELECT COALESCE(SUM(fileSize), 0) FROM remote_photos WHERE status = 'DELETED'") + fun getDeletedTotalSizeFlow(): Flow + + @Query("UPDATE remote_photos SET status = 'DELETED', deletedAt = :deletedAt, deletedByDevice = :deletedByDevice WHERE remoteId = :remoteId") + suspend fun softDelete(remoteId: String, deletedAt: Long = System.currentTimeMillis(), deletedByDevice: String? = null) + + @Query("UPDATE remote_photos SET status = 'ACTIVE', deletedAt = NULL, deletedByDevice = NULL WHERE remoteId = :remoteId") + suspend fun restore(remoteId: String) + + @Query("SELECT * FROM remote_photos WHERE uploadedByDevice = :deviceId AND status = 'ACTIVE'") + suspend fun getByDeviceId(deviceId: String): List } diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/SyncMetadataDao.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/SyncMetadataDao.kt new file mode 100644 index 00000000..5844d905 --- /dev/null +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/SyncMetadataDao.kt @@ -0,0 +1,19 @@ +package com.akslabs.cloudgallery.data.localdb.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.akslabs.cloudgallery.data.localdb.entities.SyncMetadata + +@Dao +interface SyncMetadataDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun put(item: SyncMetadata) + + @Query("SELECT value FROM sync_metadata WHERE `key` = :key") + suspend fun get(key: String): String? + + @Query("DELETE FROM sync_metadata WHERE `key` = :key") + suspend fun delete(key: String) +} diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/UploadQueueDao.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/UploadQueueDao.kt new file mode 100644 index 00000000..995ae023 --- /dev/null +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/dao/UploadQueueDao.kt @@ -0,0 +1,37 @@ +package com.akslabs.cloudgallery.data.localdb.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.akslabs.cloudgallery.data.localdb.entities.UploadQueue +import kotlinx.coroutines.flow.Flow + +@Dao +interface UploadQueueDao { + @Insert + suspend fun insert(item: UploadQueue): Long + + @Query("SELECT * FROM upload_queue WHERE status = 'PENDING' ORDER BY createdAt ASC") + suspend fun getPending(): List + + @Query("SELECT * FROM upload_queue WHERE localId = :localId AND status IN ('PENDING','IN_PROGRESS')") + suspend fun getActiveForPhoto(localId: String): UploadQueue? + + @Query("SELECT * FROM upload_queue WHERE contentHash = :hash AND status IN ('PENDING','IN_PROGRESS','DONE')") + suspend fun getByContentHash(hash: String): UploadQueue? + + @Query("UPDATE upload_queue SET status = 'IN_PROGRESS', startedAt = :startedAt WHERE id = :id") + suspend fun markInProgress(id: Long, startedAt: Long = System.currentTimeMillis()) + + @Query("UPDATE upload_queue SET status = 'DONE', completedAt = :completedAt WHERE id = :id") + suspend fun markDone(id: Long, completedAt: Long = System.currentTimeMillis()) + + @Query("UPDATE upload_queue SET status = 'FAILED', errorMessage = :error, retryCount = retryCount + 1 WHERE id = :id") + suspend fun markFailed(id: Long, error: String?) + + @Query("DELETE FROM upload_queue WHERE status = 'DONE' AND completedAt < :before") + suspend fun cleanupOld(before: Long) + + @Query("SELECT * FROM upload_queue ORDER BY createdAt DESC") + fun getAllFlow(): Flow> +} diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/Photo.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/Photo.kt index 3c19319f..298869cb 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/Photo.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/Photo.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.annotation.Keep import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty @@ -11,12 +12,25 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -@Entity(tableName = "photos") +@Entity( + tableName = "photos", + indices = [ + Index(value = ["remoteId"]), + Index(value = ["pathUri"], unique = true), + Index(value = ["contentHash"]), + Index(value = ["uploadStatus"]) + ] +) data class Photo( @PrimaryKey val localId: String, @ColumnInfo val remoteId: String? = null, @ColumnInfo val photoType: String, @ColumnInfo val pathUri: String, + @ColumnInfo val contentHash: String? = null, + @ColumnInfo val uploadStatus: String = "NONE", + @ColumnInfo val deviceId: String? = null, + @ColumnInfo val lastUploadAttempt: Long? = null, + @ColumnInfo val uploadRetryCount: Int = 0 ) : Parcelable { companion object { @@ -27,7 +41,12 @@ data class Photo( @JsonProperty("remoteId") remoteId: String? = null, @JsonProperty("photoType") photoType: String, @JsonProperty("pathUri") pathUri: String, - ): Photo = Photo(localId, remoteId, photoType, pathUri) + @JsonProperty("contentHash") contentHash: String? = null, + @JsonProperty("uploadStatus") uploadStatus: String = "NONE", + @JsonProperty("deviceId") deviceId: String? = null, + @JsonProperty("lastUploadAttempt") lastUploadAttempt: Long? = null, + @JsonProperty("uploadRetryCount") uploadRetryCount: Int = 0 + ): Photo = Photo(localId, remoteId, photoType, pathUri, contentHash, uploadStatus, deviceId, lastUploadAttempt, uploadRetryCount) } fun toRemotePhoto(): RemotePhoto { @@ -35,9 +54,12 @@ data class Photo( remoteId = remoteId.toString(), photoType = photoType, fileName = pathUri.substringAfterLast('/'), - fileSize = null, // File size not available from Photo entity + fileSize = null, uploadedAt = System.currentTimeMillis(), - thumbnailCached = false + thumbnailCached = false, + localId = localId, + contentHash = contentHash, + uploadedByDevice = deviceId ) } } diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/RemotePhoto.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/RemotePhoto.kt index 179e61a9..527b3923 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/RemotePhoto.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/RemotePhoto.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.annotation.Keep import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty @@ -11,7 +12,15 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -@Entity(tableName = "remote_photos") +@Entity( + tableName = "remote_photos", + indices = [ + Index(value = ["uploadedAt"]), + Index(value = ["contentHash"]), + Index(value = ["localId"]), + Index(value = ["status"]) + ] +) data class RemotePhoto( @PrimaryKey val remoteId: String, @ColumnInfo val photoType: String, @@ -21,6 +30,12 @@ data class RemotePhoto( @ColumnInfo val thumbnailCached: Boolean = false, @ColumnInfo val messageId: Long? = null, @ColumnInfo val uploadType: String? = null, + @ColumnInfo val localId: String? = null, + @ColumnInfo val contentHash: String? = null, + @ColumnInfo val status: String = "ACTIVE", + @ColumnInfo val deletedAt: Long? = null, + @ColumnInfo val uploadedByDevice: String? = null, + @ColumnInfo val deletedByDevice: String? = null ) : Parcelable { fun toPhoto(): Photo = Photo("", remoteId, photoType, "") @@ -37,6 +52,16 @@ data class RemotePhoto( @JsonProperty("thumbnailCached") thumbnailCached: Boolean = false, @JsonProperty("messageId") messageId: Long? = null, @JsonProperty("uploadType") uploadType: String? = null, - ): RemotePhoto = RemotePhoto(remoteId, photoType, fileName, fileSize, uploadedAt, thumbnailCached, messageId, uploadType) + @JsonProperty("localId") localId: String? = null, + @JsonProperty("contentHash") contentHash: String? = null, + @JsonProperty("status") status: String = "ACTIVE", + @JsonProperty("deletedAt") deletedAt: Long? = null, + @JsonProperty("uploadedByDevice") uploadedByDevice: String? = null, + @JsonProperty("deletedByDevice") deletedByDevice: String? = null + ): RemotePhoto = RemotePhoto( + remoteId, photoType, fileName, fileSize, uploadedAt, thumbnailCached, + messageId, uploadType, localId, contentHash, status, deletedAt, + uploadedByDevice, deletedByDevice + ) } } diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/SyncMetadata.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/SyncMetadata.kt new file mode 100644 index 00000000..8c23734b --- /dev/null +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/SyncMetadata.kt @@ -0,0 +1,12 @@ +package com.akslabs.cloudgallery.data.localdb.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "sync_metadata") +data class SyncMetadata( + @PrimaryKey val key: String, + @ColumnInfo val value: String, + @ColumnInfo val updatedAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/UploadQueue.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/UploadQueue.kt new file mode 100644 index 00000000..58e53108 --- /dev/null +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/entities/UploadQueue.kt @@ -0,0 +1,30 @@ +package com.akslabs.cloudgallery.data.localdb.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "upload_queue", + indices = [ + Index(value = ["localId"]), + Index(value = ["status"]), + Index(value = ["contentHash"]) + ] +) +data class UploadQueue( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo val localId: String, + @ColumnInfo val pathUri: String, + @ColumnInfo val contentHash: String? = null, + @ColumnInfo val status: String = "PENDING", // PENDING|IN_PROGRESS|DONE|FAILED|CANCELLED + @ColumnInfo val workerType: String, // periodic|instant|manual + @ColumnInfo val workManagerId: String? = null, + @ColumnInfo val deviceId: String, + @ColumnInfo val createdAt: Long = System.currentTimeMillis(), + @ColumnInfo val startedAt: Long? = null, + @ColumnInfo val completedAt: Long? = null, + @ColumnInfo val errorMessage: String? = null, + @ColumnInfo val retryCount: Int = 0 +) diff --git a/app/src/main/java/com/akslabs/cloudgallery/data/localdb/migration/Migration7to8_MultiDevice.kt b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/migration/Migration7to8_MultiDevice.kt new file mode 100644 index 00000000..bbe2806d --- /dev/null +++ b/app/src/main/java/com/akslabs/cloudgallery/data/localdb/migration/Migration7to8_MultiDevice.kt @@ -0,0 +1,81 @@ +package com.akslabs.cloudgallery.data.localdb.migration + +import android.util.Log +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration7to8_MultiDevice : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + Log.i("Migration7to8", "Starting migration 7 β†’ 8 (Multi-Device Support)") + + // ── photos table: new columns ── + db.execSQL("ALTER TABLE photos ADD COLUMN contentHash TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE photos ADD COLUMN uploadStatus TEXT NOT NULL DEFAULT 'NONE'") + db.execSQL("ALTER TABLE photos ADD COLUMN deviceId TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE photos ADD COLUMN lastUploadAttempt INTEGER DEFAULT NULL") + db.execSQL("ALTER TABLE photos ADD COLUMN uploadRetryCount INTEGER NOT NULL DEFAULT 0") + + // ── photos table: indexes ── + db.execSQL("CREATE INDEX IF NOT EXISTS index_photos_remoteId ON photos(remoteId)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_photos_pathUri ON photos(pathUri)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_photos_contentHash ON photos(contentHash)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_photos_uploadStatus ON photos(uploadStatus)") + + // Mark already‑uploaded photos as DONE + db.execSQL("UPDATE photos SET uploadStatus = 'DONE' WHERE remoteId IS NOT NULL") + + // ── remote_photos table: new columns ── + db.execSQL("ALTER TABLE remote_photos ADD COLUMN localId TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE remote_photos ADD COLUMN contentHash TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE remote_photos ADD COLUMN status TEXT NOT NULL DEFAULT 'ACTIVE'") + db.execSQL("ALTER TABLE remote_photos ADD COLUMN deletedAt INTEGER DEFAULT NULL") + db.execSQL("ALTER TABLE remote_photos ADD COLUMN uploadedByDevice TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE remote_photos ADD COLUMN deletedByDevice TEXT DEFAULT NULL") + + // ── remote_photos table: indexes ── + db.execSQL("CREATE INDEX IF NOT EXISTS index_remote_photos_uploadedAt ON remote_photos(uploadedAt)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_remote_photos_contentHash ON remote_photos(contentHash)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_remote_photos_localId ON remote_photos(localId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_remote_photos_status ON remote_photos(status)") + + // ── Migrate deleted_photos β†’ remote_photos with status = 'DELETED' ── + db.execSQL(""" + INSERT OR IGNORE INTO remote_photos (remoteId, photoType, fileName, fileSize, uploadedAt, thumbnailCached, messageId, status, deletedAt) + SELECT remoteId, photoType, fileName, fileSize, uploadedAt, 0, messageId, 'DELETED', deletedAt + FROM deleted_photos + """.trimIndent()) + + // ── upload_queue table ── + db.execSQL(""" + CREATE TABLE IF NOT EXISTS upload_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + localId TEXT NOT NULL, + pathUri TEXT NOT NULL, + contentHash TEXT, + status TEXT NOT NULL DEFAULT 'PENDING', + workerType TEXT NOT NULL, + workManagerId TEXT, + deviceId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + startedAt INTEGER, + completedAt INTEGER, + errorMessage TEXT, + retryCount INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent()) + db.execSQL("CREATE INDEX IF NOT EXISTS index_upload_queue_localId ON upload_queue(localId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_upload_queue_status ON upload_queue(status)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_upload_queue_contentHash ON upload_queue(contentHash)") + + // ── sync_metadata table ── + db.execSQL(""" + CREATE TABLE IF NOT EXISTS sync_metadata ( + `key` TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL, + updatedAt INTEGER NOT NULL + ) + """.trimIndent()) + + Log.i("Migration7to8", "Migration 7 β†’ 8 completed successfully") + } +} diff --git a/app/src/main/java/com/akslabs/cloudgallery/services/CloudPhotoSyncService.kt b/app/src/main/java/com/akslabs/cloudgallery/services/CloudPhotoSyncService.kt deleted file mode 100644 index 32bdec3c..00000000 --- a/app/src/main/java/com/akslabs/cloudgallery/services/CloudPhotoSyncService.kt +++ /dev/null @@ -1,315 +0,0 @@ -package com.akslabs.cloudgallery.services - -import android.content.Context -import android.util.Log -import com.akslabs.cloudgallery.api.ScanConfig -import com.akslabs.cloudgallery.data.localdb.DbHolder -import com.akslabs.cloudgallery.data.localdb.Preferences -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext -import java.util.concurrent.TimeUnit - -/** - * Service responsible for synchronizing cloud photos between Telegram channel and local database - * Handles both initial sync for new installations and periodic sync for updates - */ -object CloudPhotoSyncService { - private const val TAG = "CloudPhotoSyncService" - private const val LAST_SYNC_TIMESTAMP_KEY = "last_cloud_photo_sync_timestamp" - private const val SYNC_INTERVAL_HOURS = 24 // Sync once per day - - /** - * Perform a full synchronization of cloud photos - * This includes discovering historical images and updating existing records - */ - fun performFullSync(context: Context): Flow = flow { - Log.i(TAG, "=== STARTING FULL CLOUD PHOTO SYNC ===") - - try { - // Get the configured channel ID - val channelId = getConfiguredChannelId() - if (channelId == null) { - Log.w(TAG, "No channel ID configured, skipping sync") - Log.i(TAG, "To enable historical sync: Configure group/channel ID in app settings") - emit(com.akslabs.cloudgallery.api.ScanProgress( - currentBatch = 0, - totalFilesFound = 0, - isComplete = true, - errorMessage = "No Telegram group/channel configured for backup." - )) - return@flow - } - - Log.i(TAG, "Syncing with channel ID: $channelId") - - // IMPORTANT: Telegram Bot API Limitation Warning - Log.w(TAG, "⚠️ TELEGRAM BOT API LIMITATION:") - Log.w(TAG, "Bot API can only access messages from the last 24 hours") - Log.w(TAG, "Historical images older than 24 hours cannot be retrieved via Bot API") - Log.w(TAG, "This is a Telegram platform limitation, not an app bug") - - // Check if we need to perform sync - if (!shouldPerformSync()) { - Log.i(TAG, "Sync not needed (recent sync found)") - emit(com.akslabs.cloudgallery.api.ScanProgress( - currentBatch = 0, - totalFilesFound = 0, - isComplete = true - )) - return@flow - } - - // Get current database state - val existingRemotePhotos = withContext(Dispatchers.IO) { - DbHolder.database.remotePhotoDao().getAll() - } - Log.i(TAG, "Current database has ${existingRemotePhotos.size} RemotePhoto records") - - // Configure scan based on database state - val scanConfig = if (existingRemotePhotos.isEmpty()) { - // First time sync - scan everything - Log.i(TAG, "Performing initial sync (empty database)") - ScanConfig( - channelId = channelId, - batchSize = 50, // Smaller batches for initial sync - maxFiles = 5000, // Reasonable limit for initial sync - includePhotos = true, - includeDocuments = true, - includeVideos = true - ) - } else { - // Regular sync - look for recent additions - Log.i(TAG, "Performing incremental sync") - ScanConfig( - channelId = channelId, - batchSize = 100, - maxFiles = 1000, // Smaller limit for incremental sync - includePhotos = true, - includeDocuments = true, - includeVideos = true - ) - } - - // Perform the discovery and sync - HistoricalImageDiscoveryService.discoverAndSyncHistoricalImages( - channelId = channelId, - config = scanConfig - ).collect { progress -> - emit(progress) - } - - // Update last sync timestamp - updateLastSyncTimestamp() - - Log.i(TAG, "=== FULL CLOUD PHOTO SYNC COMPLETE ===") - - } catch (e: Exception) { - Log.e(TAG, "Exception during full sync", e) - emit(com.akslabs.cloudgallery.api.ScanProgress( - currentBatch = 0, - totalFilesFound = 0, - isComplete = true, - errorMessage = "Sync failed: ${e.message}" - )) - } - } - - /** - * Perform a quick sync to check for recent additions - * This is lighter than full sync and can be run more frequently - */ - suspend fun performQuickSync(context: Context): SyncResult { - return withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Performing quick sync") - - val channelId = getConfiguredChannelId() - if (channelId == null) { - Log.w(TAG, "No channel ID configured for quick sync") - return@withContext SyncResult.NoChannelConfigured - } - - // Quick scan of recent messages - val scanConfig = ScanConfig( - channelId = channelId, - batchSize = 50, - maxFiles = 100, - includePhotos = true, - includeDocuments = true, - includeVideos = true - ) - - var totalNewFiles = 0 - HistoricalImageDiscoveryService.discoverAndSyncHistoricalImages( - channelId = channelId, - config = scanConfig - ).collect { progress -> - if (progress.isComplete) { - totalNewFiles = progress.totalFilesFound - } - } - - Log.d(TAG, "Quick sync complete: $totalNewFiles new files") - SyncResult.Success(totalNewFiles) - - } catch (e: Exception) { - Log.e(TAG, "Exception during quick sync", e) - SyncResult.Error(e.message ?: "Unknown error") - } - } - } - - /** - * Check if a sync should be performed based on last sync timestamp - */ - private fun shouldPerformSync(): Boolean { - val lastSyncTimestamp = Preferences.getString(LAST_SYNC_TIMESTAMP_KEY, "0").toLongOrNull() ?: 0L - val currentTime = System.currentTimeMillis() - val timeSinceLastSync = currentTime - lastSyncTimestamp - val syncIntervalMs = TimeUnit.HOURS.toMillis(SYNC_INTERVAL_HOURS.toLong()) - - val shouldSync = timeSinceLastSync > syncIntervalMs - Log.d(TAG, "Last sync: $lastSyncTimestamp, Current: $currentTime, Should sync: $shouldSync") - return shouldSync - } - - /** - * Update the last sync timestamp - */ - private fun updateLastSyncTimestamp() { - val currentTime = System.currentTimeMillis() - Preferences.edit { - putString(LAST_SYNC_TIMESTAMP_KEY, currentTime.toString()) - } - Log.d(TAG, "Updated last sync timestamp to: $currentTime") - } - - /** - * Get the configured Telegram channel ID from preferences - */ - private suspend fun getConfiguredChannelId(): Long? { - return withContext(Dispatchers.IO) { - try { - // First try to get the configured group/channel ID from encrypted preferences - // This is where the user's images are actually stored - val groupChannelId = Preferences.getEncryptedLong(Preferences.channelId, 0L) - - if (groupChannelId != 0L) { - Log.d(TAG, "Found configured group/channel ID: $groupChannelId") - return@withContext groupChannelId - } - - Log.w(TAG, "No group/channel ID configured in preferences") - - // Fallback: try to get from BotApi (direct bot chat) - var botChatId = com.akslabs.cloudgallery.api.BotApi.chatId - - if (botChatId == null) { - Log.d(TAG, "BotApi.chatId is null, trying to get from preferences") - - // Try to get from stored preferences (if we stored it previously) - val storedChatId = Preferences.getString("telegram_chat_id", "") - if (storedChatId.isNotEmpty()) { - botChatId = storedChatId.toLongOrNull() - Log.d(TAG, "Found stored bot chat ID: $botChatId") - } - } - - if (botChatId == null) { - Log.w(TAG, "No chat ID available. User may need to configure group/channel ID or send /start to bot first") - } - - botChatId - } catch (e: Exception) { - Log.e(TAG, "Error getting configured channel ID", e) - null - } - } - } - - /** - * Force a sync regardless of timestamp (for manual sync) - */ - fun forceSync(context: Context): Flow = flow { - Log.i(TAG, "Force sync requested") - // Reset last sync timestamp to force sync - Preferences.edit { - putString(LAST_SYNC_TIMESTAMP_KEY, "0") - } - - // Perform full sync - performFullSync(context).collect { progress -> - emit(progress) - } - } - - /** - * Test if historical sync is properly configured - */ - suspend fun testSyncConfiguration(): String { - return withContext(Dispatchers.IO) { - try { - val groupChannelId = Preferences.getEncryptedLong(Preferences.channelId, 0L) - val botChatId = com.akslabs.cloudgallery.api.BotApi.chatId - - return@withContext when { - groupChannelId != 0L -> { - "βœ… Group/Channel ID configured: $groupChannelId. Ready for historical sync." - } - botChatId != null -> { - "⚠️ Using bot chat ID: $botChatId. Consider configuring group/channel for better organization." - } - else -> { - "❌ No Telegram group/channel configured. Please set up your backup destination." - } - } - } catch (e: Exception) { - "❌ Error checking configuration: ${e.message}" - } - } - } - - /** - * Get sync statistics - */ - suspend fun getSyncStatistics(): SyncStatistics { - return withContext(Dispatchers.IO) { - try { - val totalRemotePhotos = DbHolder.database.remotePhotoDao().getAll().size - val lastSyncTimestamp = Preferences.getString(LAST_SYNC_TIMESTAMP_KEY, "0").toLongOrNull() ?: 0L - val timeSinceLastSync = System.currentTimeMillis() - lastSyncTimestamp - - SyncStatistics( - totalCloudPhotos = totalRemotePhotos, - lastSyncTimestamp = lastSyncTimestamp, - timeSinceLastSyncMs = timeSinceLastSync, - isSyncOverdue = timeSinceLastSync > TimeUnit.HOURS.toMillis(SYNC_INTERVAL_HOURS.toLong()) - ) - } catch (e: Exception) { - Log.e(TAG, "Error getting sync statistics", e) - SyncStatistics(0, 0L, 0L, true) - } - } - } -} - -/** - * Result of a sync operation - */ -sealed class SyncResult { - data class Success(val newFilesFound: Int) : SyncResult() - data class Error(val message: String) : SyncResult() - object NoChannelConfigured : SyncResult() -} - -/** - * Statistics about the sync state - */ -data class SyncStatistics( - val totalCloudPhotos: Int, - val lastSyncTimestamp: Long, - val timeSinceLastSyncMs: Long, - val isSyncOverdue: Boolean -) diff --git a/app/src/main/java/com/akslabs/cloudgallery/services/HistoricalImageDiscoveryService.kt b/app/src/main/java/com/akslabs/cloudgallery/services/HistoricalImageDiscoveryService.kt deleted file mode 100644 index 452a4a6a..00000000 --- a/app/src/main/java/com/akslabs/cloudgallery/services/HistoricalImageDiscoveryService.kt +++ /dev/null @@ -1,217 +0,0 @@ -package com.akslabs.cloudgallery.services - -import android.util.Log -import com.akslabs.cloudgallery.api.BotApi -import com.akslabs.cloudgallery.api.ChannelScanResult -import com.akslabs.cloudgallery.api.DiscoveredMediaFile -import com.akslabs.cloudgallery.api.ScanConfig -import com.akslabs.cloudgallery.api.ScanProgress -import com.akslabs.cloudgallery.data.localdb.DbHolder -import com.akslabs.cloudgallery.data.localdb.entities.RemotePhoto -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext - -/** - * Service responsible for discovering historical images from Telegram channel - * and synchronizing them with the local RemotePhoto database - */ -object HistoricalImageDiscoveryService { - private const val TAG = "HistoricalImageDiscovery" - - /** - * Discover all historical images from the Telegram channel and sync to database - * Returns a Flow that emits progress updates during the scanning process - */ - fun discoverAndSyncHistoricalImages( - channelId: Long, - config: ScanConfig = ScanConfig(channelId) - ): Flow = flow { - Log.i(TAG, "=== STARTING HISTORICAL IMAGE DISCOVERY ===") - Log.i(TAG, "Channel ID: $channelId") - Log.i(TAG, "Config: $config") - - try { - // Get existing RemotePhoto records to avoid duplicates - val existingRemoteIds = withContext(Dispatchers.IO) { - DbHolder.database.remotePhotoDao().getAll().map { it.remoteId }.toSet() - } - Log.i(TAG, "Found ${existingRemoteIds.size} existing RemotePhoto records") - - var currentBatch = 0 - var totalFilesFound = 0 - var nextOffset: Long? = null - val newRemotePhotos = mutableListOf() - - do { - currentBatch++ - Log.d(TAG, "Processing batch $currentBatch (offset: $nextOffset)") - - // Emit progress update - emit(ScanProgress( - currentBatch = currentBatch, - totalFilesFound = totalFilesFound, - isComplete = false - )) - - // Scan the channel for media files - val scanResult = BotApi.scanChannelForMedia( - channelId = channelId, - limit = config.batchSize, - offsetMessageId = nextOffset - ) - - when (scanResult) { - is ChannelScanResult.Success -> { - Log.d(TAG, "Batch $currentBatch: Found ${scanResult.mediaFiles.size} media files") - - // Filter and process discovered media files - val filteredFiles = scanResult.mediaFiles.filter { mediaFile -> - // Skip if already exists in database - if (mediaFile.fileId in existingRemoteIds) { - Log.v(TAG, "Skipping existing file: ${mediaFile.fileId}") - return@filter false - } - - // Apply config filters - when { - mediaFile.isImage() && !config.includePhotos -> false - mediaFile.isVideo() && !config.includeVideos -> false - !mediaFile.isImage() && !mediaFile.isVideo() && !config.includeDocuments -> false - else -> true - } - } - - Log.i(TAG, "Batch $currentBatch: Processing ${filteredFiles.size} new files") - - // Convert to RemotePhoto entities - val batchRemotePhotos = filteredFiles.map { mediaFile -> - mediaFile.toRemotePhoto() - } - - newRemotePhotos.addAll(batchRemotePhotos) - totalFilesFound += filteredFiles.size - - // Update progress - emit(ScanProgress( - currentBatch = currentBatch, - totalFilesFound = totalFilesFound, - isComplete = false - )) - - // Prepare for next batch - nextOffset = scanResult.nextOffset - - // Check limits - if (totalFilesFound >= config.maxFiles) { - Log.w(TAG, "Reached max files limit (${config.maxFiles}), stopping scan") - break - } - - if (!scanResult.hasMore) { - Log.i(TAG, "No more messages to scan") - break - } - } - - is ChannelScanResult.Error -> { - Log.e(TAG, "Error scanning channel: ${scanResult.message}") - emit(ScanProgress( - currentBatch = currentBatch, - totalFilesFound = totalFilesFound, - isComplete = true, - errorMessage = scanResult.message - )) - return@flow - } - } - - } while (nextOffset != null && totalFilesFound < config.maxFiles) - - // Save all discovered RemotePhotos to database - if (newRemotePhotos.isNotEmpty()) { - Log.i(TAG, "Saving ${newRemotePhotos.size} new RemotePhoto records to database") - withContext(Dispatchers.IO) { - DbHolder.database.remotePhotoDao().insertAll(*newRemotePhotos.toTypedArray()) - } - Log.i(TAG, "Successfully saved ${newRemotePhotos.size} RemotePhoto records") - } else { - Log.i(TAG, "No new images found to sync") - } - - // Emit final progress - emit(ScanProgress( - currentBatch = currentBatch, - totalFilesFound = totalFilesFound, - isComplete = true - )) - - Log.i(TAG, "=== HISTORICAL IMAGE DISCOVERY COMPLETE ===") - Log.i(TAG, "Total batches processed: $currentBatch") - Log.i(TAG, "Total new files discovered: $totalFilesFound") - - } catch (e: Exception) { - Log.e(TAG, "Exception during historical image discovery", e) - emit(ScanProgress( - currentBatch = 0, - totalFilesFound = 0, - isComplete = true, - errorMessage = "Exception: ${e.message}" - )) - } - } - - /** - * Quick check to see if there are any historical images to discover - * Returns the estimated number of media files in the channel - */ - suspend fun estimateHistoricalImageCount(channelId: Long): Int { - return withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Estimating historical image count for channel: $channelId") - val scanResult = BotApi.scanChannelForMedia( - channelId = channelId, - limit = 100, - offsetMessageId = null - ) - - when (scanResult) { - is ChannelScanResult.Success -> { - val mediaCount = scanResult.mediaFiles.count { it.isImage() || it.isVideo() } - Log.d(TAG, "Estimated media files in first 100 messages: $mediaCount") - // Rough estimate: if we found media in first 100 messages, - // there might be more throughout the channel - if (mediaCount > 0 && scanResult.hasMore) { - mediaCount * 10 // Rough multiplier - } else { - mediaCount - } - } - is ChannelScanResult.Error -> { - Log.e(TAG, "Error estimating count: ${scanResult.message}") - 0 - } - } - } catch (e: Exception) { - Log.e(TAG, "Exception estimating historical image count", e) - 0 - } - } - } -} - -/** - * Extension function to convert DiscoveredMediaFile to RemotePhoto - */ -private fun DiscoveredMediaFile.toRemotePhoto(): RemotePhoto { - return RemotePhoto( - remoteId = fileId, - photoType = getFileExtension() ?: "unknown", - fileName = fileName, - fileSize = fileSize, - uploadedAt = uploadDate, - thumbnailCached = false, - messageId = messageId.toLong() - ) -} diff --git a/app/src/main/java/com/akslabs/cloudgallery/ui/MainActivity.kt b/app/src/main/java/com/akslabs/cloudgallery/ui/MainActivity.kt index 64a6be6a..622ef8a7 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/ui/MainActivity.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/ui/MainActivity.kt @@ -55,8 +55,8 @@ class MainActivity : ComponentActivity() { DatabaseDebugHelper.debugDatabaseState(this@MainActivity) } - // Initialize cloud photo sync on app startup - initializeCloudPhotoSync() + // Initialize hash backfill for content dedup + initializeHashBackfill() // Start daily database backup WorkModule.DailyDatabaseBackup.enqueuePeriodic() @@ -154,37 +154,18 @@ class MainActivity : ComponentActivity() { } /** - * Initialize cloud photo sync workers for automatic background sync + * Initialize hash backfill worker for content-based dedup */ - private fun initializeCloudPhotoSync() { + private fun initializeHashBackfill() { lifecycleScope.launch { try { - // Start periodic cloud photo sync (daily) - WorkModule.CloudPhotoSync.enqueue() - - // Start quick sync (every 6 hours) - WorkModule.QuickCloudSync.enqueue() - - // Trigger an immediate one-time sync if this is a fresh install - // or if it's been a while since last sync - val lastSyncTime = try { - Preferences.getLong("last_cloud_photo_sync_timestamp", 0L) - } catch (e: ClassCastException) { - Log.w("MainActivity", "Invalid sync timestamp format, resetting to 0", e) - // Clear the invalid value and set default - Preferences.edit { remove("last_cloud_photo_sync_timestamp") } - 0L - } - val daysSinceLastSync = (System.currentTimeMillis() - lastSyncTime) / (1000 * 60 * 60 * 24) - - if (lastSyncTime == 0L || daysSinceLastSync > 1) { - // Trigger immediate sync for new installs or if it's been more than a day - WorkModule.CloudPhotoSync.enqueueOneTime() - } + // Start hash backfill for existing photos without hashes + WorkModule.HashBackfill.enqueue() + // Clean up old upload queue entries + WorkModule.UploadQueueCleanup.cleanup() } catch (e: Exception) { - // Log error but don't crash the app - android.util.Log.e("MainActivity", "Error initializing cloud photo sync", e) + Log.e("MainActivity", "Error initializing hash backfill", e) } } } diff --git a/app/src/main/java/com/akslabs/cloudgallery/ui/main/MainPage.kt b/app/src/main/java/com/akslabs/cloudgallery/ui/main/MainPage.kt index c65a2f61..f3ed2288 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/ui/main/MainPage.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/ui/main/MainPage.kt @@ -117,7 +117,7 @@ fun MainPage(viewModel: MainViewModel = screenScopedViewModel()) { .collectAsStateWithLifecycle(initialValue = 0) val cloudPhotosCount by DbHolder.database.remotePhotoDao().getTotalCountFlow() .collectAsStateWithLifecycle(initialValue = 0) - val deletedPhotosCount by DbHolder.database.deletedPhotoDao().getCountFlow() + val deletedPhotosCount by DbHolder.database.remotePhotoDao().getDeletedCountFlow() .collectAsStateWithLifecycle(initialValue = 0) val photoCounts = listOf(localPhotosCount, cloudPhotosCount) @@ -154,10 +154,14 @@ fun MainPage(viewModel: MainViewModel = screenScopedViewModel()) { } } else if (currentRoute == Screens.TrashBin.route) { scope.launch(Dispatchers.IO) { - val allDeleted = DbHolder.database.deletedPhotoDao().getAll() - val allIds = allDeleted.map { it.remoteId }.toSet() + val allDeleted = DbHolder.database.remotePhotoDao().getAllRemoteIds() + // Note: getDeletedFlow returns soft-deleted items; for select-all we need IDs of deleted items + // We'll gather them from the trash view model flow or query directly + val deletedRemote = DbHolder.database.remotePhotoDao().getAllIncludingDeleted() + .filter { it.status == "DELETED" } + .map { it.remoteId }.toSet() withContext(Dispatchers.Main) { - selectedPhotos = allIds + selectedPhotos = deletedRemote } } } else { @@ -281,21 +285,9 @@ fun MainPage(viewModel: MainViewModel = screenScopedViewModel()) { }, onRestore = { scope.launch(Dispatchers.IO) { - val dao = DbHolder.database.deletedPhotoDao() val remoteDao = DbHolder.database.remotePhotoDao() selectedPhotos.forEach { id -> - val photo = dao.getById(id) - if (photo != null) { - remoteDao.insertAll(com.akslabs.cloudgallery.data.localdb.entities.RemotePhoto( - remoteId = photo.remoteId, - photoType = photo.photoType, - fileName = photo.fileName, - fileSize = photo.fileSize, - uploadedAt = photo.uploadedAt, - messageId = photo.messageId - )) - dao.delete(photo) - } + remoteDao.restore(id) } withContext(Dispatchers.Main) { ctx.toastFromMainThread("Restored ${selectedPhotos.size} photos") @@ -305,9 +297,9 @@ fun MainPage(viewModel: MainViewModel = screenScopedViewModel()) { }, onPermanentlyDelete = { scope.launch(Dispatchers.IO) { - val dao = DbHolder.database.deletedPhotoDao() + val remoteDao = DbHolder.database.remotePhotoDao() selectedPhotos.forEach { id -> - dao.deleteById(id) + remoteDao.delete(id) } withContext(Dispatchers.Main) { ctx.toastFromMainThread("Deleted ${selectedPhotos.size} photos permanently") diff --git a/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/remote/RemoteViewModel.kt b/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/remote/RemoteViewModel.kt index e1dd9181..474caf35 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/remote/RemoteViewModel.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/remote/RemoteViewModel.kt @@ -8,6 +8,7 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn import com.akslabs.cloudgallery.data.localdb.DbHolder +import com.akslabs.cloudgallery.data.localdb.Preferences import com.akslabs.cloudgallery.data.localdb.entities.RemotePhoto import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -76,26 +77,17 @@ class RemoteViewModel : ViewModel() { fun moveToTrash(selectedIds: Set) { viewModelScope.launch { val dao = DbHolder.database.remotePhotoDao() - val deletedDao = DbHolder.database.deletedPhotoDao() - val channelId = com.akslabs.cloudgallery.data.localdb.Preferences.getEncryptedLong(com.akslabs.cloudgallery.data.localdb.Preferences.channelId, 0L) + val deviceId = Preferences.getOrCreateDeviceId() + val channelId = Preferences.getEncryptedLong(Preferences.channelId, 0L) selectedIds.forEach { id -> val photo = dao.getById(id) if (photo != null) { - // Move to DeletedPhoto - val deletedPhoto = com.akslabs.cloudgallery.data.localdb.entities.DeletedPhoto( - remoteId = photo.remoteId, - photoType = photo.photoType, - fileName = photo.fileName, - fileSize = photo.fileSize, - uploadedAt = photo.uploadedAt, - deletedAt = System.currentTimeMillis(), - messageId = photo.messageId + // Soft-delete in remote_photos (status β†’ DELETED) + dao.softDelete( + remoteId = id, + deletedByDevice = deviceId ) - deletedDao.insert(deletedPhoto) - - // Delete from RemotePhoto - dao.delete(id) // Delete from Telegram if possible if (channelId != 0L && photo.messageId != null) { diff --git a/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/settings/SettingsScreen.kt b/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/settings/SettingsScreen.kt index ddbdc7f9..d25a4fd4 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/settings/SettingsScreen.kt @@ -38,7 +38,7 @@ import com.akslabs.cloudgallery.R import com.akslabs.cloudgallery.data.localdb.DbHolder import com.akslabs.cloudgallery.data.localdb.Preferences import com.akslabs.cloudgallery.data.localdb.backup.BackupHelper -import com.akslabs.cloudgallery.services.CloudPhotoSyncService + import com.akslabs.cloudgallery.utils.Constants import com.akslabs.cloudgallery.utils.MetadataConfig import com.akslabs.cloudgallery.utils.toastFromMainThread @@ -617,21 +617,7 @@ fun SettingsScreen(modifier: Modifier = Modifier.clip(RoundedCornerShape(32.dp)) } ) - SettingsItem( - icon = Icons.Rounded.CloudSync, - title = "Sync Cloud Photos", - subtitle = "Sync Manually Uploaded images from Telegram Channel", - onClick = { - scope.launch { - context.toastFromMainThread("Syncing...") - CloudPhotoSyncService.forceSync(context).collect { progress -> - if (progress.isComplete) { - context.toastFromMainThread(if (progress.errorMessage != null) "Sync failed" else "Sync complete!") - } - } - } - } - ) + SettingsItem( icon = Icons.Rounded.CloudDownload, diff --git a/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/trash/TrashBinScreen.kt b/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/trash/TrashBinScreen.kt index 20e1fe04..643775ae 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/trash/TrashBinScreen.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/trash/TrashBinScreen.kt @@ -127,17 +127,8 @@ fun TrashBinScreen( yield() // Allow other background tasks to breathe val photo = deletedPhotos.peek(index) if (photo != null) { - val remotePhoto = RemotePhoto( - remoteId = photo.remoteId, - photoType = photo.photoType, - fileName = photo.fileName, - fileSize = photo.fileSize, - uploadedAt = photo.uploadedAt, - messageId = photo.messageId - ) - val microRequest = ImageRequest.Builder(context) - .data(remotePhoto) + .data(photo) .size(64, 64) .allowHardware(true) .bitmapConfig(android.graphics.Bitmap.Config.RGB_565) @@ -148,7 +139,7 @@ fun TrashBinScreen( if (index <= lastIndex + 10) { val thumbRequest = ImageRequest.Builder(context) - .data(remotePhoto) + .data(photo) .size(180, 180) .allowHardware(true) .bitmapConfig(android.graphics.Bitmap.Config.RGB_565) @@ -251,18 +242,9 @@ fun TrashBinScreen( val photo = deletedPhotos[index] if (photo != null) { val isSelected = selectedPhotos.contains(photo.remoteId) - // Map DeletedPhoto to RemotePhoto for display - val remotePhoto = RemotePhoto( - remoteId = photo.remoteId, - photoType = photo.photoType, - fileName = photo.fileName, - fileSize = photo.fileSize, - uploadedAt = photo.uploadedAt, - messageId = photo.messageId - ) TrashPhotoItem( - remotePhoto = remotePhoto, + remotePhoto = photo, isSelected = isSelected, isScrollbarDragging = isScrollbarDragging, thumbnailResolution = thumbnailResolution, @@ -270,8 +252,6 @@ fun TrashBinScreen( if (selectionMode) { toggleSelection(photo.remoteId) } else { - // selectedIndex removed - selectedPhotoId = photo.remoteId } } diff --git a/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/trash/TrashViewModel.kt b/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/trash/TrashViewModel.kt index fd84fbef..d6696f1c 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/trash/TrashViewModel.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/ui/main/screens/trash/TrashViewModel.kt @@ -7,7 +7,7 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn import com.akslabs.cloudgallery.data.localdb.DbHolder -import com.akslabs.cloudgallery.data.localdb.entities.DeletedPhoto +import com.akslabs.cloudgallery.data.localdb.entities.RemotePhoto import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -17,20 +17,26 @@ import kotlinx.coroutines.launch class TrashViewModel : ViewModel() { - val deletedPhotosFlow: Flow> by lazy { + // Use soft-deleted remote photos instead of legacy deleted_photos table + val deletedPhotosFlow: Flow> by lazy { Pager( config = PagingConfig(pageSize = 24), - pagingSourceFactory = { DbHolder.database.deletedPhotoDao().getAllPaging() } + pagingSourceFactory = { DbHolder.database.remotePhotoDao().getDeletedPagingSource() } ).flow.cachedIn(viewModelScope) } val totalSize: StateFlow by lazy { - DbHolder.database.deletedPhotoDao().getTotalSizeFlow() + DbHolder.database.remotePhotoDao().getDeletedTotalSizeFlow() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L) } + val deletedPhotosCount: StateFlow by lazy { + DbHolder.database.remotePhotoDao().getDeletedCountFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + } + val allDeletedPhotos: StateFlow> by lazy { - DbHolder.database.deletedPhotoDao().getAllFlow() + DbHolder.database.remotePhotoDao().getDeletedFlow() .map { list -> list.map { photo -> com.akslabs.cloudgallery.data.localdb.entities.Photo( @@ -44,36 +50,18 @@ class TrashViewModel : ViewModel() { .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) } - fun restore(photo: DeletedPhoto) { + fun restore(photo: RemotePhoto) { viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val remoteDao = DbHolder.database.remotePhotoDao() - val deletedDao = DbHolder.database.deletedPhotoDao() - - // Move back to remote table - remoteDao.insertAll( - com.akslabs.cloudgallery.data.localdb.entities.RemotePhoto( - remoteId = photo.remoteId, - photoType = photo.photoType, - fileName = photo.fileName, - fileSize = photo.fileSize, - uploadedAt = photo.uploadedAt, - messageId = photo.messageId - ) - ) - // Remove from deleted table - deletedDao.delete(photo) + val dao = DbHolder.database.remotePhotoDao() + // Simply change status back to ACTIVE + dao.restore(photo.remoteId) } } - fun permanentlyDelete(photo: DeletedPhoto) { + fun permanentlyDelete(photo: RemotePhoto) { viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - DbHolder.database.deletedPhotoDao().delete(photo) - // Note: We already deleted from Telegram when moving to trash, - // or if we didn't, we can try again here if we want, but usually "Trash" implies it's already "gone" from the main view. - // If the user wants to ensure it's gone from Telegram, we did that in moveToTrash. - // If we want to support "Delete from Telegram ONLY when permanently deleting", we should change moveToTrash. - // But the user said "ensure selected images gets deleted from chat ... and show those photos in trash bin". - // So we delete from chat FIRST. + // Hard-delete from database + DbHolder.database.remotePhotoDao().delete(photo.remoteId) } } } diff --git a/app/src/main/java/com/akslabs/cloudgallery/utils/ContentHasher.kt b/app/src/main/java/com/akslabs/cloudgallery/utils/ContentHasher.kt new file mode 100644 index 00000000..ca771228 --- /dev/null +++ b/app/src/main/java/com/akslabs/cloudgallery/utils/ContentHasher.kt @@ -0,0 +1,43 @@ +package com.akslabs.cloudgallery.utils + +import android.content.Context +import android.net.Uri +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.security.MessageDigest + +object ContentHasher { + + private const val TAG = "ContentHasher" + + /** + * Computes SHA-256 hash of file content pointed to by [uri]. + * Returns hex-encoded hash string, or null on failure. + */ + suspend fun computeHash(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) { + try { + val digest = MessageDigest.getInstance("SHA-256") + context.contentResolver.openInputStream(uri)?.use { stream -> + val buffer = ByteArray(8192) + var read: Int + while (stream.read(buffer).also { read = it } != -1) { + digest.update(buffer, 0, read) + } + } + digest.digest().joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + Log.e(TAG, "Failed to hash $uri", e) + null + } + } + + /** + * Computes SHA-256 hash of a byte array. + */ + fun computeHash(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(bytes) + return digest.digest().joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/com/akslabs/cloudgallery/utils/Utils.kt b/app/src/main/java/com/akslabs/cloudgallery/utils/Utils.kt index 9887b060..cb670316 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/utils/Utils.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/utils/Utils.kt @@ -18,10 +18,9 @@ import androidx.compose.animation.scaleOut import com.akslabs.cloudgallery.R import com.akslabs.cloudgallery.api.BotApi import com.akslabs.cloudgallery.data.localdb.DbHolder +import com.akslabs.cloudgallery.data.localdb.Preferences import com.akslabs.cloudgallery.data.localdb.entities.Photo import com.akslabs.cloudgallery.data.localdb.entities.RemotePhoto -import com.github.kotlintelegrambot.entities.files.Document -import com.github.kotlintelegrambot.network.fold import java.io.File import java.io.FileOutputStream import kotlin.random.Random @@ -95,17 +94,19 @@ suspend fun sendFileViaUri( botApi: BotApi, context: android.content.Context, uploadType: String? = null, - fileName: String? = null -) { + fileName: String? = null, + contentHash: String? = null +): Boolean { val mimeType: String? = getMimeTypeFromUri(contentResolver, uri) val fileExtension = getExtFromMimeType(mimeType!!) val originalFileName = fileName ?: getFileName(contentResolver, uri) val inputStream = contentResolver.openInputStream(uri) + var success = false inputStream?.use { ipStream -> val tempFile = File.createTempFile(Random.nextLong().toString(), ".$fileExtension") val outputStream = FileOutputStream(tempFile) ipStream.copyTo(outputStream) - sendFileApi( + success = sendFileApi( botApi, channelId, uri, @@ -113,14 +114,21 @@ suspend fun sendFileViaUri( fileExtension!!, context, uploadType, - originalFileName + originalFileName, + contentHash ) outputStream.close() Log.d(TAG, tempFile.name) tempFile.deleteOnExit() } + return success } +/** + * Sends a file to Telegram and records it in the database. + * Returns true if the upload was successful, false otherwise. + * Throws IOException if the upload fails so callers can handle it. + */ suspend fun sendFileApi( botApi: BotApi, channelId: Long, @@ -129,23 +137,56 @@ suspend fun sendFileApi( extension: String, context: android.content.Context, uploadType: String? = null, - fileName: String? = null -) { + fileName: String? = null, + contentHash: String? = null +): Boolean { + val deviceId = Preferences.getOrCreateDeviceId() var message: com.github.kotlintelegrambot.entities.Message? = null + var uploadError: Exception? = null - // Extract metadata and create caption if enabled - val caption = if (MetadataConfig.shouldIncludeMetadata()) { + // Build caption: metadata + device/hash tags + val metadataCaption = if (MetadataConfig.shouldIncludeMetadata()) { val metadata = ImageMetadataExtractor.extractMetadata(context, pathUri) metadata?.toTelegramCaption() } else { null } - botApi.sendFile(file, channelId, caption).fold( - { apiResponse -> - message = apiResponse?.result + // Append device and hash tags + val deviceTag = "#device:$deviceId" + val hashTag = if (contentHash != null) " #hash:$contentHash" else "" + val caption = buildString { + if (metadataCaption != null) { + append(metadataCaption) + append("\n") } - ) + append(deviceTag) + append(hashTag) + } + + Log.d(TAG, "⬆️ sendFileApi: Uploading file=${file.name} (${file.length()} bytes) to channel=$channelId") + Log.d(TAG, "⬆️ sendFileApi: caption=$caption") + + val result = botApi.sendFile(file, channelId, caption) + val (response, error) = result + + if (error != null) { + Log.e(TAG, "❌ sendFileApi: Network/API error during upload", error) + uploadError = error + } else if (response != null) { + val body = response.body() + if (response.isSuccessful && body?.result != null) { + message = body.result + Log.d(TAG, "βœ… sendFileApi: Telegram API returned success, messageId=${message?.messageId}") + } else { + val errorBody = response.errorBody()?.string() + Log.e(TAG, "❌ sendFileApi: Telegram API error - code=${response.code()}, body=$errorBody") + uploadError = java.io.IOException("Telegram API error ${response.code()}: $errorBody") + } + } else { + Log.e(TAG, "❌ sendFileApi: Both response and error are null") + uploadError = java.io.IOException("Upload returned null response") + } val doc = message?.document val photoSize = message?.photo?.maxByOrNull { it.fileSize ?: 0 } @@ -162,7 +203,7 @@ suspend fun sendFileApi( // Atomically update the remoteId for the photo using its pathUri DbHolder.database.photoDao().updateRemoteIdForPath(pathUri.toString(), fileId) - // Insert/replace RemotePhoto so Cloud screen picks it up immediately + // Insert/replace RemotePhoto with device metadata DbHolder.database.remotePhotoDao().insertAll( RemotePhoto( remoteId = fileId, @@ -172,12 +213,17 @@ suspend fun sendFileApi( uploadedAt = System.currentTimeMillis(), thumbnailCached = false, messageId = message?.messageId, - uploadType = uploadType + uploadType = uploadType, + contentHash = contentHash, + uploadedByDevice = deviceId ) ) - Log.d(TAG, "sendFile: Success! Metadata included in caption.") + Log.d(TAG, "βœ… sendFile: Success! fileId=$fileId, Device: $deviceId, Hash: ${contentHash?.take(8) ?: "none"}") + return true } else { - Log.d(TAG, "sendFile: Failed!") + Log.e(TAG, "❌ sendFile: Upload FAILED - no fileId in response. Error: ${uploadError?.message}") + // Throw so callers know the upload failed + throw uploadError ?: java.io.IOException("Upload failed: no fileId in Telegram response") } } diff --git a/app/src/main/java/com/akslabs/cloudgallery/workers/CloudPhotoSyncWorker.kt b/app/src/main/java/com/akslabs/cloudgallery/workers/CloudPhotoSyncWorker.kt deleted file mode 100644 index aa34008f..00000000 --- a/app/src/main/java/com/akslabs/cloudgallery/workers/CloudPhotoSyncWorker.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.akslabs.cloudgallery.workers - -import android.content.Context -import android.content.pm.ServiceInfo -import android.util.Log -import androidx.work.CoroutineWorker -import androidx.work.ForegroundInfo -import androidx.work.WorkerParameters -import com.akslabs.cloudgallery.R -import com.akslabs.cloudgallery.services.CloudPhotoSyncService -import com.akslabs.cloudgallery.utils.NotificationHelper - -/** - * Background worker for syncing cloud photos from Telegram channel - * Runs periodically to discover new historical images and sync them to local database - */ -class CloudPhotoSyncWorker( - context: Context, - workerParams: WorkerParameters -) : CoroutineWorker(context, workerParams) { - - override suspend fun doWork(): Result { - Log.i(TAG, "=== CLOUD PHOTO SYNC WORKER STARTED ===") - - return try { - // Set foreground info for long-running operation - setForeground(createForegroundInfo()) - - // Perform sync - var finalSyncResult: com.akslabs.cloudgallery.services.SyncResult? = null - CloudPhotoSyncService.performFullSync(applicationContext).collect { progress -> - // Update progress in foreground notification - if (progress.isComplete) { - finalSyncResult = if (progress.errorMessage != null) { - com.akslabs.cloudgallery.services.SyncResult.Error(progress.errorMessage) - } else { - com.akslabs.cloudgallery.services.SyncResult.Success(progress.totalFilesFound) - } - } else { - // Update notification with progress - setProgress( - androidx.work.workDataOf( - "batch" to progress.currentBatch, - "found" to progress.totalFilesFound - ) - ) - } - } - - when (val result = finalSyncResult) { - is com.akslabs.cloudgallery.services.SyncResult.Success -> { - val newFiles = result.newFilesFound - Log.i(TAG, "Cloud photo sync completed successfully: $newFiles new files") - if (newFiles > 0) { - // Show notification about new photos found - NotificationHelper.showCloudSyncCompleteNotification( - applicationContext, - newFiles - ) - } - Result.success() - } - - is com.akslabs.cloudgallery.services.SyncResult.Error -> { - Log.e(TAG, "Cloud photo sync failed: ${result.message}") - Result.failure() - } - - is com.akslabs.cloudgallery.services.SyncResult.NoChannelConfigured -> { - Log.w(TAG, "No Telegram channel configured, skipping sync") - Result.success() // Not a failure, just nothing to do - } - - null -> { - Log.e(TAG, "Sync result was null") - Result.failure() - } - } - - } catch (e: Exception) { - Log.e(TAG, "Exception in CloudPhotoSyncWorker", e) - Result.failure() - } finally { - Log.i(TAG, "=== CLOUD PHOTO SYNC WORKER FINISHED ===") - } - } - - override suspend fun getForegroundInfo(): ForegroundInfo { - return createForegroundInfo( - applicationContext, - WorkModule.NOTIFICATION_ID_SYNC, - applicationContext.getString(R.string.syncing_cloud_photos) - ) - } - - private fun createForegroundInfo(): ForegroundInfo { - return createForegroundInfo( - applicationContext, - WorkModule.NOTIFICATION_ID_SYNC, - applicationContext.getString(R.string.syncing_cloud_photos) - ) - } - - companion object { - private const val TAG = "CloudPhotoSyncWorker" - private const val NOTIFICATION_ID = 2001 - } -} - -/** - * Worker for quick sync operations (lighter than full sync) - * Used for more frequent checks without heavy processing - */ -class QuickCloudSyncWorker( - context: Context, - workerParams: WorkerParameters -) : CoroutineWorker(context, workerParams) { - - override suspend fun doWork(): Result { - Log.d(TAG, "Quick cloud sync worker started") - - return try { - val syncResult = CloudPhotoSyncService.performQuickSync(applicationContext) - - when (syncResult) { - is com.akslabs.cloudgallery.services.SyncResult.Success -> { - Log.d(TAG, "Quick sync completed: ${syncResult.newFilesFound} new files") - Result.success() - } - - is com.akslabs.cloudgallery.services.SyncResult.Error -> { - Log.e(TAG, "Quick sync failed: ${syncResult.message}") - Result.retry() - } - - is com.akslabs.cloudgallery.services.SyncResult.NoChannelConfigured -> { - Log.d(TAG, "No channel configured for quick sync") - Result.success() - } - } - - } catch (e: Exception) { - Log.e(TAG, "Exception in QuickCloudSyncWorker", e) - Result.retry() - } - } - - companion object { - private const val TAG = "QuickCloudSyncWorker" - } -} diff --git a/app/src/main/java/com/akslabs/cloudgallery/workers/DownloadMissingPhotosWorker.kt b/app/src/main/java/com/akslabs/cloudgallery/workers/DownloadMissingPhotosWorker.kt index 15afa010..e520a20e 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/workers/DownloadMissingPhotosWorker.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/workers/DownloadMissingPhotosWorker.kt @@ -11,8 +11,10 @@ import androidx.work.WorkerParameters import com.akslabs.cloudgallery.R import com.akslabs.cloudgallery.api.BotApi import com.akslabs.cloudgallery.data.localdb.DbHolder +import com.akslabs.cloudgallery.data.localdb.Preferences import com.akslabs.cloudgallery.data.localdb.entities.Photo import com.akslabs.cloudgallery.data.localdb.entities.RemotePhoto +import com.akslabs.cloudgallery.utils.ContentHasher import com.akslabs.cloudgallery.utils.getMimeTypeFromExt import com.akslabs.cloudgallery.utils.toastFromMainThread import java.io.ByteArrayInputStream @@ -31,19 +33,28 @@ class DownloadMissingPhotosWorker( } return withContext(Dispatchers.IO) { try { - // Get all remote photos and filter for those not on device + val deviceId = Preferences.getOrCreateDeviceId() + + // Only get ACTIVE remote photos (not DELETED ones) val allRemotePhotos = DbHolder.database.remotePhotoDao().getAll() val allLocalPhotos = DbHolder.database.photoDao().getAll() val localRemoteIds = allLocalPhotos.mapNotNull { it.remoteId }.toSet() + val localContentHashes = allLocalPhotos.mapNotNull { it.contentHash }.toSet() val remotePhotosNotOnDevice = allRemotePhotos.filter { remotePhoto -> - remotePhoto.remoteId !in localRemoteIds + remotePhoto.remoteId !in localRemoteIds && + // Content-hash dedup: skip if same content already on device + (remotePhoto.contentHash == null || remotePhoto.contentHash !in localContentHashes) } val photosToInsert = mutableListOf() remotePhotosNotOnDevice.forEach { remotePhoto -> val byteArray = BotApi.getFile(remotePhoto.remoteId)!! + + // Compute content hash for the downloaded file + val contentHash = ContentHasher.computeHash(byteArray) + val inStream = ByteArrayInputStream(byteArray) val contentValues = ContentValues().apply { put( @@ -81,7 +92,10 @@ class DownloadMissingPhotosWorker( localId = uri.lastPathSegment!!, remoteId = remotePhoto.remoteId, photoType = remotePhoto.photoType, - pathUri = uri.toString() + pathUri = uri.toString(), + contentHash = contentHash, + uploadStatus = "DONE", + deviceId = deviceId ) ) } diff --git a/app/src/main/java/com/akslabs/cloudgallery/workers/HashBackfillWorker.kt b/app/src/main/java/com/akslabs/cloudgallery/workers/HashBackfillWorker.kt new file mode 100644 index 00000000..ab1222d6 --- /dev/null +++ b/app/src/main/java/com/akslabs/cloudgallery/workers/HashBackfillWorker.kt @@ -0,0 +1,71 @@ +package com.akslabs.cloudgallery.workers + +import android.content.Context +import android.util.Log +import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.akslabs.cloudgallery.data.localdb.DbHolder +import com.akslabs.cloudgallery.utils.ContentHasher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Background worker that computes SHA-256 content hashes for existing photos + * that don't have one yet. Runs with low priority in batches of 50. + */ +class HashBackfillWorker( + private val context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + val photoDao = DbHolder.database.photoDao() + val remotePhotoDao = DbHolder.database.remotePhotoDao() + val photosNeedingHash = photoDao.getAllNeedingHash() + + if (photosNeedingHash.isEmpty()) { + Log.d(TAG, "No photos need hashing") + return@withContext Result.success() + } + + Log.i(TAG, "Hashing ${photosNeedingHash.size} photos") + + // Process in batches of 50 + photosNeedingHash.chunked(BATCH_SIZE).forEachIndexed { batchIndex, batch -> + batch.forEach { photo -> + try { + val hash = ContentHasher.computeHash(context, photo.pathUri.toUri()) + if (hash != null) { + photoDao.updateContentHash(photo.localId, hash) + + // Also update the linked RemotePhoto if it exists + if (photo.remoteId != null) { + val remotePhoto = remotePhotoDao.getById(photo.remoteId) + if (remotePhoto != null && remotePhoto.contentHash == null) { + remotePhotoDao.insertAll(remotePhoto.copy(contentHash = hash)) + } + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to hash photo ${photo.localId}: ${e.message}") + // Continue with next photo β€” don't fail the whole batch + } + } + Log.d(TAG, "Batch ${batchIndex + 1} complete (${(batchIndex + 1) * BATCH_SIZE}/${photosNeedingHash.size})") + } + + Log.i(TAG, "Hash backfill complete") + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Hash backfill failed", e) + Result.retry() + } + } + + companion object { + private const val TAG = "HashBackfillWorker" + private const val BATCH_SIZE = 50 + } +} diff --git a/app/src/main/java/com/akslabs/cloudgallery/workers/InstantPhotoDownloadWorker.kt b/app/src/main/java/com/akslabs/cloudgallery/workers/InstantPhotoDownloadWorker.kt index 3cce5289..34bec5ee 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/workers/InstantPhotoDownloadWorker.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/workers/InstantPhotoDownloadWorker.kt @@ -12,7 +12,9 @@ import androidx.work.workDataOf import com.akslabs.cloudgallery.R import com.akslabs.cloudgallery.api.BotApi import com.akslabs.cloudgallery.data.localdb.DbHolder +import com.akslabs.cloudgallery.data.localdb.Preferences import com.akslabs.cloudgallery.data.localdb.entities.Photo +import com.akslabs.cloudgallery.utils.ContentHasher import com.akslabs.cloudgallery.utils.getMimeTypeFromExt import com.akslabs.cloudgallery.utils.toastFromMainThread import kotlinx.coroutines.Dispatchers @@ -40,8 +42,9 @@ class InstantPhotoDownloadWorker( return withContext(Dispatchers.IO) { try { + val deviceId = Preferences.getOrCreateDeviceId() + Log.d(TAG, "Getting remote photo from database for remoteId: $remoteId") - // Get remote photo details from database val remotePhoto = DbHolder.database.remotePhotoDao().getById(remoteId) ?: return@withContext Result.failure(workDataOf(KEY_RESULT_ERROR to "Remote photo not found")) @@ -55,12 +58,14 @@ class InstantPhotoDownloadWorker( } Log.d(TAG, "Starting download from Telegram for remoteId: $remoteId") - // Download file from Telegram val byteArray = BotApi.getFile(remoteId) ?: return@withContext Result.failure(workDataOf(KEY_RESULT_ERROR to "Failed to download file")) Log.d(TAG, "Downloaded ${byteArray.size} bytes") + // Compute content hash for the downloaded file + val contentHash = ContentHasher.computeHash(byteArray) + val inStream = ByteArrayInputStream(byteArray) val contentValues = ContentValues().apply { put( @@ -87,21 +92,30 @@ class InstantPhotoDownloadWorker( contentValues ) ?: return@withContext Result.failure(workDataOf(KEY_RESULT_ERROR to "Failed to create file")) - // Copy data to file resolver.openOutputStream(uri).use { outStream -> inStream.copyTo(outStream!!) } - // Insert photo into local database + // Insert photo with device-aware metadata val photo = Photo( localId = uri.lastPathSegment!!, remoteId = remotePhoto.remoteId, photoType = remotePhoto.photoType, - pathUri = uri.toString() + pathUri = uri.toString(), + contentHash = contentHash, + uploadStatus = "DONE", + deviceId = deviceId ) DbHolder.database.photoDao().insertPhotos(photo) + + // Also update content hash on the remote photo if missing + if (remotePhoto.contentHash == null) { + DbHolder.database.remotePhotoDao().insertAll( + remotePhoto.copy(contentHash = contentHash) + ) + } - Log.d(TAG, "Download completed successfully for remoteId: $remoteId") + Log.d(TAG, "Download completed successfully for remoteId: $remoteId (hash: ${contentHash.take(8)})") context.toastFromMainThread("Photo downloaded successfully!") Result.success() } catch (e: Exception) { diff --git a/app/src/main/java/com/akslabs/cloudgallery/workers/InstantPhotoUploadWorker.kt b/app/src/main/java/com/akslabs/cloudgallery/workers/InstantPhotoUploadWorker.kt index fd10bd92..dc326afc 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/workers/InstantPhotoUploadWorker.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/workers/InstantPhotoUploadWorker.kt @@ -10,9 +10,11 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import com.akslabs.cloudgallery.R import com.akslabs.cloudgallery.api.BotApi +import com.akslabs.cloudgallery.data.localdb.DbHolder import com.akslabs.cloudgallery.data.localdb.Preferences +import com.akslabs.cloudgallery.data.localdb.entities.UploadQueue +import com.akslabs.cloudgallery.utils.ContentHasher import com.akslabs.cloudgallery.utils.sendFileViaUri -import com.akslabs.cloudgallery.workers.WorkModule.NOTIFICATION_ID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -34,21 +36,94 @@ class InstantPhotoUploadWorker( return withContext(Dispatchers.IO) { try { + val deviceId = Preferences.getOrCreateDeviceId() + val photoDao = DbHolder.database.photoDao() + val remotePhotoDao = DbHolder.database.remotePhotoDao() + val uploadQueueDao = DbHolder.database.uploadQueueDao() + val photoUriString = params.inputData.getString(KEY_PHOTO_URI)!! val photoUri = photoUriString.toUri() val fileName = com.akslabs.cloudgallery.utils.getFileName(appContext.contentResolver, photoUri) val uploadType = params.inputData.getString(KEY_UPLOAD_TYPE) ?: "instant" - setProgress( - workDataOf( - "progress" to "started", - KEY_PHOTO_URI to photoUriString, - KEY_FILE_NAME to fileName, - KEY_UPLOAD_TYPE to uploadType + // Find the photo in database by path URI + val photo = photoDao.getAll().find { it.pathUri == photoUriString } + + if (photo != null) { + // 1. Check if already uploaded + if (photo.uploadStatus == "DONE" || photo.remoteId != null) { + Log.d("PhotoUpload", "Skipping: already uploaded (${photo.localId})") + return@withContext Result.success() + } + + // 2. Check for active queue entry + val activeEntry = uploadQueueDao.getActiveForPhoto(photo.localId) + if (activeEntry != null) { + Log.d("PhotoUpload", "Skipping: active queue entry (${photo.localId})") + return@withContext Result.success() + } + + // 3. Compute content hash + val hash = photo.contentHash ?: ContentHasher.computeHash(appContext, photoUri) + if (hash != null && photo.contentHash == null) { + photoDao.updateContentHash(photo.localId, hash) + } + + // 4. Content-based dedup + if (hash != null) { + val existing = remotePhotoDao.getByContentHash(hash) + if (existing != null) { + Log.d("PhotoUpload", "Dedup: content already in cloud (hash match)") + photoDao.updateRemoteIdForLocalId(photo.localId, existing.remoteId) + photoDao.updateUploadStatus(photo.localId, "DONE", System.currentTimeMillis()) + return@withContext Result.success() + } + } + + // 5. Create queue entry and mark uploading + val queueId = uploadQueueDao.insert(UploadQueue( + localId = photo.localId, + pathUri = photo.pathUri, + contentHash = hash, + workerType = "instant", + deviceId = deviceId + )) + photoDao.updateUploadStatus(photo.localId, "UPLOADING", System.currentTimeMillis()) + uploadQueueDao.markInProgress(queueId) + + setProgress( + workDataOf( + "progress" to "started", + KEY_PHOTO_URI to photoUriString, + KEY_FILE_NAME to fileName, + KEY_UPLOAD_TYPE to uploadType + ) + ) + + // 6. Upload with hash + try { + sendFileViaUri(photoUri, appContext.contentResolver, channelId, botApi, appContext, uploadType, fileName, hash) + photoDao.updateUploadStatus(photo.localId, "DONE", System.currentTimeMillis()) + uploadQueueDao.markDone(queueId) + } catch (e: Exception) { + photoDao.updateUploadStatus(photo.localId, "FAILED", System.currentTimeMillis()) + uploadQueueDao.markFailed(queueId, e.message) + throw e + } + } else { + // Photo not in our database (e.g. direct share) β€” upload without dedup + setProgress( + workDataOf( + "progress" to "started", + KEY_PHOTO_URI to photoUriString, + KEY_FILE_NAME to fileName, + KEY_UPLOAD_TYPE to uploadType + ) ) - ) - sendFileViaUri(photoUri, appContext.contentResolver, channelId, botApi, appContext, uploadType, fileName) + val hash = ContentHasher.computeHash(appContext, photoUri) + sendFileViaUri(photoUri, appContext.contentResolver, channelId, botApi, appContext, uploadType, fileName, hash) + } Result.success(workDataOf( KEY_PHOTO_URI to photoUriString, diff --git a/app/src/main/java/com/akslabs/cloudgallery/workers/PeriodicPhotoBackupWorker.kt b/app/src/main/java/com/akslabs/cloudgallery/workers/PeriodicPhotoBackupWorker.kt index 9fb4b172..6e7fcc5a 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/workers/PeriodicPhotoBackupWorker.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/workers/PeriodicPhotoBackupWorker.kt @@ -4,7 +4,6 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Log -import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEachIndexed import androidx.core.net.toUri import androidx.work.CoroutineWorker @@ -15,10 +14,11 @@ import com.akslabs.cloudgallery.R import com.akslabs.cloudgallery.api.BotApi import com.akslabs.cloudgallery.data.localdb.DbHolder import com.akslabs.cloudgallery.data.localdb.Preferences +import com.akslabs.cloudgallery.data.localdb.entities.UploadQueue +import com.akslabs.cloudgallery.utils.ContentHasher import com.akslabs.cloudgallery.utils.getExtFromMimeType import com.akslabs.cloudgallery.utils.getMimeTypeFromUri import com.akslabs.cloudgallery.utils.sendFileApi -import com.akslabs.cloudgallery.workers.WorkModule.NOTIFICATION_ID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream @@ -34,17 +34,21 @@ class PeriodicPhotoBackupWorker( private val channelId: Long = Preferences.getEncryptedLong(Preferences.channelId, 0L) private val botApi: BotApi = BotApi + override suspend fun doWork(): Result { - val compressionThresholdInBytes = params.inputData.getLong( - KEY_COMPRESSION_THRESHOLD, - 0L - ) val uploadType = params.inputData.getString(KEY_UPLOAD_TYPE) - val imageList = DbHolder.database.photoDao().getAllNotUploaded() + val deviceId = Preferences.getOrCreateDeviceId() + val photoDao = DbHolder.database.photoDao() + val remotePhotoDao = DbHolder.database.remotePhotoDao() + val uploadQueueDao = DbHolder.database.uploadQueueDao() + + // Use new dedup-aware query instead of getAllNotUploaded() + val imageList = photoDao.getAllPendingUpload() + return withContext(Dispatchers.IO) { try { - Log.d("PeriodicBackup", "Found ${imageList.size} photos not uploaded") - lateinit var tempFile: File + Log.d("PeriodicBackup", "Found ${imageList.size} photos pending upload (deviceId=$deviceId)") + var tempFile: File? = null // Initial progress setProgress( @@ -55,9 +59,40 @@ class PeriodicPhotoBackupWorker( ) ) - imageList.fastForEachIndexed { index, photo -> + imageList.forEachIndexed { index, photo -> Log.d("PeriodicBackup", "Processing ${index + 1}/${imageList.size}: ${photo.pathUri}") + // 1. Re-check status (another worker may have uploaded it) + val current = photoDao.getPhotoByLocalId(photo.localId) ?: return@forEachIndexed + if (current.uploadStatus == "DONE" || current.remoteId != null) { + Log.d("PeriodicBackup", "Skipping ${photo.localId}: already uploaded") + return@forEachIndexed + } + + // 2. Check upload queue for active entry from another worker + val activeQueueEntry = uploadQueueDao.getActiveForPhoto(photo.localId) + if (activeQueueEntry != null) { + Log.d("PeriodicBackup", "Skipping ${photo.localId}: active queue entry exists") + return@forEachIndexed + } + + // 3. Compute content hash if missing + val hash = current.contentHash ?: ContentHasher.computeHash(appContext, current.pathUri.toUri()) + if (hash != null && current.contentHash == null) { + photoDao.updateContentHash(current.localId, hash) + } + + // 4. Content-based dedup: check if already uploaded (by any device) + if (hash != null) { + val existing = remotePhotoDao.getByContentHash(hash) + if (existing != null) { + Log.d("PeriodicBackup", "Dedup: ${photo.localId} already in cloud (hash match)") + photoDao.updateRemoteIdForLocalId(current.localId, existing.remoteId) + photoDao.updateUploadStatus(current.localId, "DONE", System.currentTimeMillis()) + return@forEachIndexed + } + } + // Update progress val fileName = com.akslabs.cloudgallery.utils.getFileName(appContext.contentResolver, photo.pathUri.toUri()) setProgress( @@ -65,21 +100,34 @@ class PeriodicPhotoBackupWorker( KEY_PROGRESS_CURRENT to index + 1, KEY_PROGRESS_MAX to imageList.size, KEY_CURRENT_FILE_URI to photo.pathUri, - "fileName" to fileName // Explicitly using "fileName" key to match worker constant + "fileName" to fileName ) ) + // 5. Create queue entry + val queueId = uploadQueueDao.insert(UploadQueue( + localId = current.localId, + pathUri = current.pathUri, + contentHash = hash, + workerType = "periodic", + deviceId = deviceId + )) + + // 6. Mark uploading + photoDao.updateUploadStatus(current.localId, "UPLOADING", System.currentTimeMillis()) + uploadQueueDao.markInProgress(queueId) + val uri = photo.pathUri.toUri() try { val mimeType = getMimeTypeFromUri(appContext.contentResolver, uri) val ext = getExtFromMimeType(mimeType!!) - val FIFTY_MB = 50L * 1024 * 1024 // 50 MB in bytes + val FIFTY_MB = 50L * 1024 * 1024 val bytes = appContext.contentResolver.openInputStream(uri)?.use { it.readBytes() }!! - var outputBytes = bytes // default β€” no compression + var outputBytes = bytes var quality = 100 // Compress only if image > 50 MB @@ -98,7 +146,7 @@ class PeriodicPhotoBackupWorker( bitmap.compress(compressFormat, quality, it) outputBytes = it.toByteArray() } - quality -= 5 // reduce quality by 5 each iteration + quality -= 5 } while (outputBytes.size > FIFTY_MB && quality > 0) } @@ -106,13 +154,21 @@ class PeriodicPhotoBackupWorker( Random.nextLong().toString(), ".$ext" ) - tempFile.writeBytes(outputBytes) - sendFileApi(botApi, channelId, uri, tempFile, ext!!, appContext, uploadType, fileName) + tempFile!!.writeBytes(outputBytes) + + // 7. Upload with hash and device metadata + sendFileApi(botApi, channelId, uri, tempFile!!, ext!!, appContext, uploadType, fileName, hash) + + photoDao.updateUploadStatus(current.localId, "DONE", System.currentTimeMillis()) + uploadQueueDao.markDone(queueId) + Log.d("PeriodicBackup", "Upload success: ${photo.localId}") } catch (e: IOException) { - Log.e("PeriodicBackup", "IO error on photo ${index + 1}, will retry: ${e.message}") - return@withContext Result.retry() + Log.e("PeriodicBackup", "Upload failed for ${photo.localId}: ${e.message}") + photoDao.updateUploadStatus(current.localId, "FAILED", System.currentTimeMillis()) + uploadQueueDao.markFailed(queueId, e.message) + // Continue with next photo instead of retrying entire batch } finally { - tempFile.deleteOnExit() + tempFile?.deleteOnExit() } } val lastUri = if (imageList.isNotEmpty()) imageList.last().pathUri else null diff --git a/app/src/main/java/com/akslabs/cloudgallery/workers/SyncDbMediaStoreWorker.kt b/app/src/main/java/com/akslabs/cloudgallery/workers/SyncDbMediaStoreWorker.kt index 2df83c16..1b328ab1 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/workers/SyncDbMediaStoreWorker.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/workers/SyncDbMediaStoreWorker.kt @@ -11,6 +11,7 @@ import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.akslabs.cloudgallery.R import com.akslabs.cloudgallery.data.localdb.DbHolder +import com.akslabs.cloudgallery.data.localdb.Preferences import com.akslabs.cloudgallery.data.localdb.entities.Photo import com.akslabs.cloudgallery.data.mediastore.getPhotoFromCursor import com.akslabs.cloudgallery.utils.toastFromMainThread @@ -25,6 +26,7 @@ class SyncDbMediaStoreWorker( override suspend fun doWork(): Result { return withContext(Dispatchers.Default) { try { + val deviceId = Preferences.getOrCreateDeviceId() val resolver = context.contentResolver val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) val projection = arrayOf( @@ -42,7 +44,9 @@ class SyncDbMediaStoreWorker( cursor?.use { while (cursor.moveToNext()) { try { - photosOnDevice.add(cursor.getPhotoFromCursor()) + val photo = cursor.getPhotoFromCursor() + // Set deviceId on new photos + photosOnDevice.add(photo.copy(deviceId = deviceId)) } catch (e: Exception) { Log.d(TAG, "doWork: ${e.localizedMessage}") } @@ -57,7 +61,7 @@ class SyncDbMediaStoreWorker( deletedPhotos.fastForEach { DbHolder.database.photoDao().deleteById(it.localId) } - Log.d("Sync MediaStore", "doWork: Success") + Log.d("Sync MediaStore", "doWork: Success (deviceId=$deviceId)") Result.success() } catch (e: Exception) { Log.d("Sync MediaStore", "doWork: ${e.localizedMessage}") diff --git a/app/src/main/java/com/akslabs/cloudgallery/workers/SyncRemoteSizesWorker.kt b/app/src/main/java/com/akslabs/cloudgallery/workers/SyncRemoteSizesWorker.kt index 91b8e0ff..07c8ecfc 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/workers/SyncRemoteSizesWorker.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/workers/SyncRemoteSizesWorker.kt @@ -35,27 +35,22 @@ class SyncRemoteSizesWorker( return@withContext Result.success() } - Log.i(TAG, "Found ${photosWithoutSize.size} photos without size info. Scanning channel...") + Log.i(TAG, "Found ${photosWithoutSize.size} photos without size info. Fetching individually...") - // We scan the channel to find matching files - // This is a heavy operation, so we might want to limit it or do it incrementally - // For now, we'll scan recent history - - val scanResult = BotApi.scanChannelForMedia(channelId, limit = 500) - - if (scanResult is com.akslabs.cloudgallery.api.ChannelScanResult.Success) { - val mediaMap = scanResult.mediaFiles.associateBy { it.fileId } - var updatedCount = 0 - - photosWithoutSize.forEach { photo -> - val media = mediaMap[photo.remoteId] - if (media != null && media.fileSize != null) { - dao.insertAll(photo.copy(fileSize = media.fileSize)) + var updatedCount = 0 + photosWithoutSize.forEach { photo -> + try { + val fileSize = BotApi.getFileSize(photo.remoteId) + if (fileSize != null) { + dao.insertAll(photo.copy(fileSize = fileSize)) updatedCount++ } + } catch (e: Exception) { + Log.w(TAG, "Failed to get size for ${photo.remoteId}: ${e.message}") + // Continue with next photo } - Log.i(TAG, "Updated size for $updatedCount photos.") } + Log.i(TAG, "Updated size for $updatedCount photos.") Result.success() } catch (e: Exception) { diff --git a/app/src/main/java/com/akslabs/cloudgallery/workers/WorkModule.kt b/app/src/main/java/com/akslabs/cloudgallery/workers/WorkModule.kt index b3f006c8..9d510e89 100644 --- a/app/src/main/java/com/akslabs/cloudgallery/workers/WorkModule.kt +++ b/app/src/main/java/com/akslabs/cloudgallery/workers/WorkModule.kt @@ -265,66 +265,26 @@ object WorkModule { } } - object CloudPhotoSync { + object HashBackfill { private val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() - private val periodicCloudSyncRequest = - PeriodicWorkRequestBuilder(Duration.ofDays(1)) - .setConstraints(constraints) - .setInitialDelay(Duration.ofMinutes(30)) // Wait 30 minutes after app install - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofMinutes(15)) - .build() - fun enqueue() { - manager.enqueueUniquePeriodicWork( - CLOUD_PHOTO_SYNC_WORK, - ExistingPeriodicWorkPolicy.KEEP, - periodicCloudSyncRequest - ) - } - - fun cancel() { - manager.cancelUniqueWork(CLOUD_PHOTO_SYNC_WORK) - } - - fun enqueueOneTime() { - val oneTimeRequest = OneTimeWorkRequestBuilder() + val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofMinutes(5)) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofMinutes(15)) .build() manager.enqueueUniqueWork( - CLOUD_PHOTO_SYNC_ONE_TIME_WORK, - ExistingWorkPolicy.REPLACE, - oneTimeRequest - ) - } - } - - object QuickCloudSync { - private val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - private val periodicQuickSyncRequest = - PeriodicWorkRequestBuilder(Duration.ofHours(6)) - .setConstraints(constraints) - .setInitialDelay(Duration.ofHours(1)) - .build() - - fun enqueue() { - manager.enqueueUniquePeriodicWork( - QUICK_CLOUD_SYNC_WORK, - ExistingPeriodicWorkPolicy.KEEP, - periodicQuickSyncRequest + HASH_BACKFILL_WORK, + ExistingWorkPolicy.KEEP, + request ) } fun cancel() { - manager.cancelUniqueWork(QUICK_CLOUD_SYNC_WORK) + manager.cancelUniqueWork(HASH_BACKFILL_WORK) } } @@ -337,7 +297,7 @@ object WorkModule { Duration.ofDays(1) ) .setConstraints(constraints) - .setInitialDelay(Duration.ofHours(1)) // Start 1 hour after app install + .setInitialDelay(Duration.ofHours(1)) .build() fun enqueuePeriodic() { @@ -356,6 +316,21 @@ object WorkModule { } } + object UploadQueueCleanup { + /** + * Cleans up completed upload_queue entries older than 7 days. + * Called from DailyDatabaseBackupWorker or on app launch. + */ + suspend fun cleanup() { + try { + val sevenDaysAgo = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L) + com.akslabs.cloudgallery.data.localdb.DbHolder.database.uploadQueueDao().cleanupOld(sevenDaysAgo) + } catch (e: Exception) { + android.util.Log.e("UploadQueueCleanup", "Failed to clean up old queue entries", e) + } + } + } + fun observeWorkerByName(name: String) = manager.getWorkInfosForUniqueWorkFlow(name) .flowOn(Dispatchers.IO) @@ -366,9 +341,7 @@ object WorkModule { const val SYNC_MEDIA_STORE_WORK = "SyncMediaStoreWork" const val RESTORE_ALL_PHOTOS_WORK = "RestoreAllPhotosWork" const val PERIODIC_DB_EXPORT_WORK = "PeriodicDbExportWork" - const val CLOUD_PHOTO_SYNC_WORK = "CloudPhotoSyncWork" - const val CLOUD_PHOTO_SYNC_ONE_TIME_WORK = "CloudPhotoSyncOneTimeWork" - const val QUICK_CLOUD_SYNC_WORK = "QuickCloudSyncWork" + const val HASH_BACKFILL_WORK = "HashBackfillWork" const val DAILY_DATABASE_BACKUP_WORK = "DailyDatabaseBackupWork" const val UPLOADING_ID = "UploadingId" const val DOWNLOADING_ID = "DownloadingId"