diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt index 5495831f95..48238ae29d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt @@ -9,9 +9,6 @@ import androidx.annotation.RequiresApi import androidx.annotation.RequiresExtension import kotlinx.serialization.json.Json import java.io.File -import java.time.Instant -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter class MediaManager(context: Context) { private val ctx: Context = context.applicationContext @@ -30,158 +27,140 @@ class MediaManager(context: Context) { } @RequiresApi(Build.VERSION_CODES.Q) - fun shouldFullSync(callback: (Result) -> Unit) { + fun shouldFullSync(): Boolean { val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - val currVersion = MediaStore.getVersion(ctx) - val lastVersion = prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null) - callback(Result.success(currVersion != lastVersion)) + return MediaStore.getVersion(ctx) != prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null) } @RequiresApi(Build.VERSION_CODES.Q) @RequiresExtension(extension = Build.VERSION_CODES.R, version = 1) - fun checkpointSync(callback: (Result) -> Unit) { + fun checkpointSync() { val genMap = MediaStore.getExternalVolumeNames(ctx).associateWith { MediaStore.getGeneration(ctx, it) } - val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - prefs.edit().apply { + ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit().apply { putString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, MediaStore.getVersion(ctx)) putString(SHARED_PREF_MEDIA_STORE_GEN_KEY, Json.encodeToString(genMap)) apply() } - callback(Result.success(Unit)) } @RequiresApi(Build.VERSION_CODES.Q) - fun getAssetIdsForAlbum(albumId: String, callback: (Result>) -> Unit) { - try { - val uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) - val projection = arrayOf(MediaStore.Files.FileColumns._ID) - val selection = - "${MediaStore.Files.FileColumns.BUCKET_ID} = ? AND (${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)" - val selectionArgs = arrayOf( - albumId, - MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), - MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString() - ) + fun getAssetIdsForAlbum(albumId: String): List { + val uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + val projection = arrayOf(MediaStore.Files.FileColumns._ID) + val selection = + "${MediaStore.Files.FileColumns.BUCKET_ID} = ? AND (${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)" + val selectionArgs = arrayOf( + albumId, + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString() + ) - val ids = - ctx.contentResolver.query(uri, projection, selection, selectionArgs, null)?.use { cursor -> - val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) - generateSequence { - if (cursor.moveToNext()) cursor.getLong(idColumn).toString() else null - }.toList() - } ?: emptyList() - - callback(Result.success(ids)) - } catch (e: Exception) { - callback(Result.failure(e)) - } + return ctx.contentResolver.query(uri, projection, selection, selectionArgs, null) + ?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + generateSequence { + if (cursor.moveToNext()) cursor.getLong(idColumn).toString() else null + }.toList() + } ?: emptyList() } @RequiresApi(Build.VERSION_CODES.R) @RequiresExtension(extension = Build.VERSION_CODES.R, version = 1) - fun getMediaChanges(callback: (Result) -> Unit) { - try { - val genMap = getSavedGenerationMap(ctx) - val currentVolumes = MediaStore.getExternalVolumeNames(ctx) - val changed = mutableListOf() - val deleted = mutableListOf() - val formatter = DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneOffset.UTC) + fun getMediaChanges(): SyncDelta { + val genMap = getSavedGenerationMap(ctx) + val currentVolumes = MediaStore.getExternalVolumeNames(ctx) + val changed = mutableListOf() + val deleted = mutableListOf() - var hasChanges = genMap.keys != currentVolumes - for (volume in currentVolumes) { - val currentGen = MediaStore.getGeneration(ctx, volume) - val storedGen = genMap[volume] - if (storedGen != null && currentGen <= storedGen) { - continue - } - hasChanges = true + var hasChanges = genMap.keys != currentVolumes + for (volume in currentVolumes) { + val currentGen = MediaStore.getGeneration(ctx, volume) + val storedGen = genMap[volume] + if (storedGen != null && currentGen <= storedGen) { + continue + } + hasChanges = true - val uri = MediaStore.Files.getContentUri(volume) - val projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DATA, - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.DATE_TAKEN, - MediaStore.MediaColumns.DATE_ADDED, - MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.Files.FileColumns.MEDIA_TYPE, - MediaStore.MediaColumns.BUCKET_ID, - MediaStore.MediaColumns.DURATION - ) + val uri = MediaStore.Files.getContentUri(volume) + val projection = arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DATA, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.DATE_TAKEN, + MediaStore.MediaColumns.DATE_ADDED, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.Files.FileColumns.MEDIA_TYPE, + MediaStore.MediaColumns.BUCKET_ID, + MediaStore.MediaColumns.DURATION + ) - val selection = - "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?) AND (${MediaStore.MediaColumns.GENERATION_MODIFIED} > ? OR ${MediaStore.MediaColumns.GENERATION_ADDED} > ?)" - val selectionArgs = arrayOf( - MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), - MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString(), - storedGen?.toString() ?: "0", - storedGen?.toString() ?: "0" - ) + val selection = + "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?) AND (${MediaStore.MediaColumns.GENERATION_MODIFIED} > ? OR ${MediaStore.MediaColumns.GENERATION_ADDED} > ?)" + val selectionArgs = arrayOf( + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString(), + storedGen?.toString() ?: "0", + storedGen?.toString() ?: "0" + ) - ctx.contentResolver.query( - uri, - projection, - selection, - selectionArgs, - null - )?.use { cursor -> - val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) - val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) - val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) - val dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN) - val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) - val dateModifiedColumn = - cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) - val mediaTypeColumn = - cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) - val bucketIdColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) - val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION) + ctx.contentResolver.query( + uri, + projection, + selection, + selectionArgs, + null + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) + val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) + val dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN) + val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) + val dateModifiedColumn = + cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) + val mediaTypeColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) + val bucketIdColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) + val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION) - while (cursor.moveToNext()) { - val id = cursor.getLong(idColumn).toString() - val path = cursor.getString(dataColumn) - if (path.isBlank() || !File(path).exists()) { - deleted.add(id) - continue - } - - val mediaType = cursor.getInt(mediaTypeColumn) - val name = cursor.getString(nameColumn) - // Date taken is milliseconds since epoch, Date added is seconds since epoch - val takenAt = cursor.getLong(dateTakenColumn).takeIf { it > 0 } ?: (cursor.getLong( - dateAddedColumn - ) * 1000) - val createdAt = formatter.format(Instant.ofEpochMilli(takenAt)) - // Date modified is seconds since epoch - val modifiedAt = - formatter.format(Instant.ofEpochMilli(cursor.getLong(dateModifiedColumn) * 1000)) - // Duration is milliseconds - val duration = - if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0 else cursor.getLong( - durationColumn - ) / 1000 - val bucketId = cursor.getString(bucketIdColumn) - - changed.add( - Asset( - id, - name, - mediaType.toLong(), - createdAt, - modifiedAt, - duration, - listOf(bucketId) - ) - ) + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn).toString() + val path = cursor.getString(dataColumn) + if (path.isBlank() || !File(path).exists()) { + deleted.add(id) + continue } + + val mediaType = cursor.getInt(mediaTypeColumn) + val name = cursor.getString(nameColumn) + // Date taken is milliseconds since epoch, Date added is seconds since epoch + val createdAt = (cursor.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) + ?: cursor.getLong(dateAddedColumn) + // Date modified is seconds since epoch + val modifiedAt = cursor.getLong(dateModifiedColumn) * 1000 + // Duration is milliseconds + val duration = + if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0 else cursor.getLong( + durationColumn + ) / 1000 + val bucketId = cursor.getString(bucketIdColumn) + + changed.add( + Asset( + id, + name, + mediaType.toLong(), + createdAt, + modifiedAt, + duration, + listOf(bucketId) + ) + ) } } - // Unmounted volumes are handled in dart when the album is removed - - callback(Result.success(SyncDelta(hasChanges, changed, deleted))) - } catch (e: Exception) { - callback(Result.failure(e)) } + // Unmounted volumes are handled in dart when the album is removed + + return SyncDelta(hasChanges, changed, deleted) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt index b4682d28db..b3c83f1846 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt @@ -82,8 +82,8 @@ data class Asset ( val id: String, val name: String, val type: Long, - val createdAt: String? = null, - val updatedAt: String? = null, + val createdAt: Long? = null, + val updatedAt: Long? = null, val durationInSeconds: Long, val albumIds: List ) @@ -93,8 +93,8 @@ data class Asset ( val id = pigeonVar_list[0] as String val name = pigeonVar_list[1] as String val type = pigeonVar_list[2] as Long - val createdAt = pigeonVar_list[3] as String? - val updatedAt = pigeonVar_list[4] as String? + val createdAt = pigeonVar_list[3] as Long? + val updatedAt = pigeonVar_list[4] as Long? val durationInSeconds = pigeonVar_list[5] as Long val albumIds = pigeonVar_list[6] as List return Asset(id, name, type, createdAt, updatedAt, durationInSeconds, albumIds) @@ -187,13 +187,12 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { } } - /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface ImHostService { - fun shouldFullSync(callback: (Result) -> Unit) - fun getMediaChanges(callback: (Result) -> Unit) - fun checkpointSync(callback: (Result) -> Unit) - fun getAssetIdsForAlbum(albumId: String, callback: (Result>) -> Unit) + fun shouldFullSync(): Boolean + fun getMediaChanges(): SyncDelta + fun checkpointSync() + fun getAssetIdsForAlbum(albumId: String): List companion object { /** The codec used by ImHostService. */ @@ -209,15 +208,12 @@ interface ImHostService { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { _, reply -> - api.shouldFullSync{ result: Result -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(MessagesPigeonUtils.wrapError(error)) - } else { - val data = result.getOrNull() - reply.reply(MessagesPigeonUtils.wrapResult(data)) - } + val wrapped: List = try { + listOf(api.shouldFullSync()) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) } + reply.reply(wrapped) } } else { channel.setMessageHandler(null) @@ -227,15 +223,12 @@ interface ImHostService { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue) if (api != null) { channel.setMessageHandler { _, reply -> - api.getMediaChanges{ result: Result -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(MessagesPigeonUtils.wrapError(error)) - } else { - val data = result.getOrNull() - reply.reply(MessagesPigeonUtils.wrapResult(data)) - } + val wrapped: List = try { + listOf(api.getMediaChanges()) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) } + reply.reply(wrapped) } } else { channel.setMessageHandler(null) @@ -245,14 +238,13 @@ interface ImHostService { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { _, reply -> - api.checkpointSync{ result: Result -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(MessagesPigeonUtils.wrapError(error)) - } else { - reply.reply(MessagesPigeonUtils.wrapResult(null)) - } + val wrapped: List = try { + api.checkpointSync() + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) } + reply.reply(wrapped) } } else { channel.setMessageHandler(null) @@ -264,15 +256,12 @@ interface ImHostService { channel.setMessageHandler { message, reply -> val args = message as List val albumIdArg = args[0] as String - api.getAssetIdsForAlbum(albumIdArg) { result: Result> -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(MessagesPigeonUtils.wrapError(error)) - } else { - val data = result.getOrNull() - reply.reply(MessagesPigeonUtils.wrapResult(data)) - } + val wrapped: List = try { + listOf(api.getAssetIdsForAlbum(albumIdArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) } + reply.reply(wrapped) } } else { channel.setMessageHandler(null) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MessagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MessagesImpl.kt index 96e03d5e0e..556ff8e2d7 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MessagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MessagesImpl.kt @@ -20,38 +20,27 @@ class MessagesImpl(context: Context) : ImHostService { IllegalStateException("Method not supported on this Android version.") } - override fun shouldFullSync(callback: (Result) -> Unit) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - mediaManager.shouldFullSync(callback) - } else { - callback(Result.success(true)) + override fun shouldFullSync(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || mediaManager.shouldFullSync() + + override fun getMediaChanges(): SyncDelta { + if (!isMediaChangesSupported()) { + throw unsupportedFeatureException() } + return mediaManager.getMediaChanges() } - override fun getMediaChanges(callback: (Result) -> Unit) { - if (isMediaChangesSupported()) { - mediaManager.getMediaChanges(callback) - } else { - callback(Result.failure(unsupportedFeatureException())) + override fun checkpointSync() { + if (!isMediaChangesSupported()) { + return } + mediaManager.checkpointSync() } - override fun checkpointSync(callback: (Result) -> Unit) { - if (isMediaChangesSupported()) { - mediaManager.checkpointSync(callback) - } else { - callback(Result.success(Unit)) - } - } - - override fun getAssetIdsForAlbum( - albumId: String, - callback: (Result>) -> Unit - ) { - if (isMediaChangesSupported()) { - mediaManager.getAssetIdsForAlbum(albumId, callback) - } else { - callback(Result.failure(unsupportedFeatureException())) + override fun getAssetIdsForAlbum(albumId: String): List { + if (!isMediaChangesSupported()) { + throw unsupportedFeatureException() } + return mediaManager.getAssetIdsForAlbum(albumId) } } diff --git a/mobile/ios/Runner/Platform/MediaManager.swift b/mobile/ios/Runner/Platform/MediaManager.swift index d3aa3a323a..362113c12b 100644 --- a/mobile/ios/Runner/Platform/MediaManager.swift +++ b/mobile/ios/Runner/Platform/MediaManager.swift @@ -1,7 +1,7 @@ import Photos -class WrapperAsset: Hashable, Equatable { - var asset: Asset +struct AssetWrapper: Hashable, Equatable { + let asset: Asset init(with asset: Asset) { self.asset = asset @@ -11,22 +11,22 @@ class WrapperAsset: Hashable, Equatable { hasher.combine(self.asset.id) } - static func == (lhs: WrapperAsset, rhs: WrapperAsset) -> Bool { + static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool { return lhs.asset.id == rhs.asset.id } } class MediaManager { - private let _defaults: UserDefaults - private let _changeTokenKey = "immich:changeToken" + private let defaults: UserDefaults + private let changeTokenKey = "immich:changeToken" init(with defaults: UserDefaults = .standard) { - self._defaults = defaults + self.defaults = defaults } @available(iOS 16, *) - func _getChangeToken() -> PHPersistentChangeToken? { - guard let encodedToken = _defaults.data(forKey: _changeTokenKey) else { + private func getChangeToken() -> PHPersistentChangeToken? { + guard let encodedToken = defaults.data(forKey: changeTokenKey) else { print("MediaManager::_getChangeToken: Change token not available in UserDefaults") return nil } @@ -40,10 +40,10 @@ class MediaManager { } @available(iOS 16, *) - func _saveChangeToken(token: PHPersistentChangeToken) -> Void { + private func saveChangeToken(token: PHPersistentChangeToken) -> Void { do { let encodedToken = try NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) - _defaults.set(encodedToken, forKey: _changeTokenKey) + defaults.set(encodedToken, forKey: changeTokenKey) print("MediaManager::_setChangeToken: Change token saved to UserDefaults") } catch { print("MediaManager::_setChangeToken: Failed to persist the token to UserDefaults: \(error)") @@ -51,109 +51,96 @@ class MediaManager { } @available(iOS 16, *) - func checkpointSync(completion: @escaping (Result) -> Void) { - _saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) - completion(.success(())) + func checkpointSync() { + saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) } @available(iOS 16, *) - func shouldFullSync(completion: @escaping (Result) -> Void) { + func shouldFullSync() -> Bool { guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { // When we do not have access to photo library, return true to fallback to old sync - completion(.success(true)) - return + return true } - guard let storedToken = _getChangeToken() else { + guard let storedToken = getChangeToken() else { // No token exists, perform the initial full sync print("MediaManager::shouldUseOldSync: No token found. Full sync required") - completion(.success(true)) - return + return true } do { _ = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) - completion(.success(false)) + return false } catch { // fallback to using old sync when we cannot detect changes using the available token print("MediaManager::shouldUseOldSync: fetchPersistentChanges failed with error (\(error))") - completion(.success(true)) } - + return true } @available(iOS 16, *) - func getMediaChanges(completion: @escaping (Result) -> Void) { + func getMediaChanges() throws -> SyncDelta { guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { - completion(.failure(PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil))) - return + throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil) } - guard let storedToken = _getChangeToken() else { + guard let storedToken = getChangeToken() else { // No token exists, definitely need a full sync print("MediaManager::getMediaChanges: No token found") - completion(.failure(PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil))) - return + throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil) } let currentToken = PHPhotoLibrary.shared().currentChangeToken if storedToken == currentToken { - completion(.success(SyncDelta(hasChanges: false, updates: [], deletes: []))) - return + return SyncDelta(hasChanges: false, updates: [], deletes: []) } do { - let result = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - var updatedArr: Set = [] - var deletedArr: Set = [] - - for changes in result { - let details = try changes.changeDetails(for: PHObjectType.asset) - - let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) - let deleted = details.deletedLocalIdentifiers + let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) - let options = PHFetchOptions() - options.includeHiddenAssets = false + var updatedAssets: Set = [] + var deletedAssets: Set = [] + + for change in changes { + guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } - let updatedAssets = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options) - - updatedAssets.enumerateObjects { (asset, _, _) in - + let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) + if (updated.isEmpty) { continue } + + let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: nil) + for i in 0..) -> Void) - func getMediaChanges(completion: @escaping (Result) -> Void) - func checkpointSync(completion: @escaping (Result) -> Void) - func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void) + func shouldFullSync() throws -> Bool + func getMediaChanges() throws -> SyncDelta + func checkpointSync() throws + func getAssetIdsForAlbum(albumId: String) throws -> [String] } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -274,13 +273,11 @@ class ImHostServiceSetup { let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { shouldFullSyncChannel.setMessageHandler { _, reply in - api.shouldFullSync { result in - switch result { - case .success(let res): - reply(wrapResult(res)) - case .failure(let error): - reply(wrapError(error)) - } + do { + let result = try api.shouldFullSync() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) } } } else { @@ -291,13 +288,11 @@ class ImHostServiceSetup { : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) if let api = api { getMediaChangesChannel.setMessageHandler { _, reply in - api.getMediaChanges { result in - switch result { - case .success(let res): - reply(wrapResult(res)) - case .failure(let error): - reply(wrapError(error)) - } + do { + let result = try api.getMediaChanges() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) } } } else { @@ -306,13 +301,11 @@ class ImHostServiceSetup { let checkpointSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { checkpointSyncChannel.setMessageHandler { _, reply in - api.checkpointSync { result in - switch result { - case .success: - reply(wrapResult(nil)) - case .failure(let error): - reply(wrapError(error)) - } + do { + try api.checkpointSync() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) } } } else { @@ -325,13 +318,11 @@ class ImHostServiceSetup { getAssetIdsForAlbumChannel.setMessageHandler { message, reply in let args = message as! [Any?] let albumIdArg = args[0] as! String - api.getAssetIdsForAlbum(albumId: albumIdArg) { result in - switch result { - case .success(let res): - reply(wrapResult(res)) - case .failure(let error): - reply(wrapError(error)) - } + do { + let result = try api.getAssetIdsForAlbum(albumId: albumIdArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) } } } else { diff --git a/mobile/ios/Runner/Platform/MessagesImpl.swift b/mobile/ios/Runner/Platform/MessagesImpl.swift index 3dc00ecb3c..ea1282d88c 100644 --- a/mobile/ios/Runner/Platform/MessagesImpl.swift +++ b/mobile/ios/Runner/Platform/MessagesImpl.swift @@ -1,39 +1,36 @@ import Photos class ImHostServiceImpl: ImHostService { - let _mediaManager: MediaManager + private let mediaManager: MediaManager init() { - self._mediaManager = MediaManager() + self.mediaManager = MediaManager() } - func shouldFullSync(completion: @escaping (Result) -> Void) { - if #available(iOS 16, *) { - _mediaManager.shouldFullSync(completion: completion) + func shouldFullSync() throws -> Bool { + return if #available(iOS 16, *) { + mediaManager.shouldFullSync() } else { // Always fall back to full sync on older iOS versions - completion(.success(true)) + true } } - func getMediaChanges(completion: @escaping (Result) -> Void) { + func getMediaChanges() throws -> SyncDelta { guard #available(iOS 16, *) else { - completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil))) - return + throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) } - _mediaManager.getMediaChanges(completion: completion) + return try mediaManager.getMediaChanges() } - func checkpointSync(completion: @escaping (Result) -> Void) { + func checkpointSync() throws { if #available(iOS 16, *) { - _mediaManager.checkpointSync(completion: completion) - } else { - completion(.success(())) + mediaManager.checkpointSync() } } - func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], any Error>) -> Void) { + func getAssetIdsForAlbum(albumId: String) throws -> [String] { // Android specific, empty list is safe no-op - completion(.success([])) + return [] } } diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 21acb4556d..48fd093091 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -354,10 +354,12 @@ extension on platform.Asset { id: id, name: name, type: AssetType.values.elementAtOrNull(type) ?? AssetType.other, - createdAt: - createdAt == null ? DateTime.now() : DateTime.parse(createdAt!), - updatedAt: - updatedAt == null ? DateTime.now() : DateTime.parse(updatedAt!), + createdAt: createdAt == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(createdAt! * 1000), + updatedAt: updatedAt == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(updatedAt! * 1000), durationInSeconds: durationInSeconds, ); } diff --git a/mobile/lib/platform/messages.dart b/mobile/lib/platform/messages.dart index 58df82b355..a364c99a66 100644 --- a/mobile/lib/platform/messages.dart +++ b/mobile/lib/platform/messages.dart @@ -16,8 +16,9 @@ class Asset { final String id; final String name; final int type; // follows AssetType enum from base_asset.model.dart - final String? createdAt; - final String? updatedAt; + // Seconds since epoch + final int? createdAt; + final int? updatedAt; final int durationInSeconds; final List albumIds; @@ -25,10 +26,10 @@ class Asset { required this.id, required this.name, required this.type, - required this.createdAt, - required this.updatedAt, - required this.durationInSeconds, - required this.albumIds, + this.createdAt, + this.updatedAt, + this.durationInSeconds = 0, + this.albumIds = const [], }); } @@ -45,17 +46,13 @@ class SyncDelta { @HostApi() abstract class ImHostService { - @async bool shouldFullSync(); - @async @TaskQueue(type: TaskQueueType.serialBackgroundThread) SyncDelta getMediaChanges(); - @async void checkpointSync(); - @async @TaskQueue(type: TaskQueueType.serialBackgroundThread) List getAssetIdsForAlbum(String albumId); } diff --git a/mobile/lib/platform/messages.g.dart b/mobile/lib/platform/messages.g.dart index b53a5ee140..8e2b51f0b0 100644 --- a/mobile/lib/platform/messages.g.dart +++ b/mobile/lib/platform/messages.g.dart @@ -14,22 +14,21 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } - bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { return a.length == b.length && a.indexed - .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && - a.entries.every((MapEntry entry) => - (b as Map).containsKey(entry.key) && - _deepEquals(entry.value, b[entry.key])); + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); } return a == b; } + class Asset { Asset({ required this.id, @@ -47,9 +46,9 @@ class Asset { int type; - String? createdAt; + int? createdAt; - String? updatedAt; + int? updatedAt; int durationInSeconds; @@ -68,8 +67,7 @@ class Asset { } Object encode() { - return _toList(); - } + return _toList(); } static Asset decode(Object result) { result as List; @@ -77,8 +75,8 @@ class Asset { id: result[0]! as String, name: result[1]! as String, type: result[2]! as int, - createdAt: result[3] as String?, - updatedAt: result[4] as String?, + createdAt: result[3] as int?, + updatedAt: result[4] as int?, durationInSeconds: result[5]! as int, albumIds: (result[6] as List?)!.cast(), ); @@ -98,7 +96,8 @@ class Asset { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } class SyncDelta { @@ -123,8 +122,7 @@ class SyncDelta { } Object encode() { - return _toList(); - } + return _toList(); } static SyncDelta decode(Object result) { result as List; @@ -149,9 +147,11 @@ class SyncDelta { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -159,10 +159,10 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is Asset) { + } else if (value is Asset) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is SyncDelta) { + } else if (value is SyncDelta) { buffer.putUint8(130); writeValue(buffer, value.encode()); } else { @@ -173,9 +173,9 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: return Asset.decode(readValue(buffer)!); - case 130: + case 130: return SyncDelta.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -187,11 +187,9 @@ class ImHostService { /// Constructor for [ImHostService]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. - ImHostService( - {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + ImHostService({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = - messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -199,10 +197,8 @@ class ImHostService { final String pigeonVar_messageChannelSuffix; Future shouldFullSync() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -229,10 +225,8 @@ class ImHostService { } Future getMediaChanges() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -259,10 +253,8 @@ class ImHostService { } Future checkpointSync() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -284,16 +276,13 @@ class ImHostService { } Future> getAssetIdsForAlbum(String albumId) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = - pigeonVar_channel.send([albumId]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) {