feat: native sync ios

This commit is contained in:
shenlong-tanwen 2025-05-02 19:52:17 +05:30
parent dbe1a127c9
commit 214893d2f4
27 changed files with 1757 additions and 971 deletions

View File

@ -0,0 +1,278 @@
// Autogenerated from Pigeon (v25.3.1), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object MessagesPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).containsKey(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
/** Generated class from Pigeon that represents data sent in messages. */
data class Asset (
val id: String,
val name: String,
val type: Long,
val createdAt: String? = null,
val updatedAt: String? = null,
val durationInSeconds: Long,
val albumIds: List<String>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): 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 durationInSeconds = pigeonVar_list[5] as Long
val albumIds = pigeonVar_list[6] as List<String>
return Asset(id, name, type, createdAt, updatedAt, durationInSeconds, albumIds)
}
}
fun toList(): List<Any?> {
return listOf(
id,
name,
type,
createdAt,
updatedAt,
durationInSeconds,
albumIds,
)
}
override fun equals(other: Any?): Boolean {
if (other !is Asset) {
return false
}
if (this === other) {
return true
}
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SyncDelta (
val updates: List<Asset>,
val deletes: List<String>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SyncDelta {
val updates = pigeonVar_list[0] as List<Asset>
val deletes = pigeonVar_list[1] as List<String>
return SyncDelta(updates, deletes)
}
}
fun toList(): List<Any?> {
return listOf(
updates,
deletes,
)
}
override fun equals(other: Any?): Boolean {
if (other !is SyncDelta) {
return false
}
if (this === other) {
return true
}
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class messagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
Asset.fromList(it)
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is Asset -> {
stream.write(129)
writeValue(stream, value.toList())
}
is SyncDelta -> {
stream.write(130)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ImHostService {
fun shouldFullSync(callback: (Result<Boolean>) -> Unit)
fun hasMediaChanges(callback: (Result<Boolean>) -> Unit)
fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit)
fun checkpointSync(callback: (Result<Unit>) -> Unit)
companion object {
/** The codec used by ImHostService. */
val codec: MessageCodec<Any?> by lazy {
messagesPigeonCodec()
}
/** Sets up an instance of `ImHostService` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: ImHostService?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val taskQueue = binaryMessenger.makeBackgroundTaskQueue()
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.shouldFullSync{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.hasMediaChanges$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.hasMediaChanges{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.getMediaChanges{ result: Result<SyncDelta> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.checkpointSync{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
reply.reply(MessagesPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -5,31 +5,26 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57
url: "https://pub.dev"
source: hosted
version: "76.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.3"
version: "80.0.0"
analyzer:
dependency: "direct main"
description:
name: analyzer
sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e"
url: "https://pub.dev"
source: hosted
version: "6.11.0"
version: "7.3.0"
analyzer_plugin:
dependency: "direct main"
description:
name: analyzer_plugin
sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4
url: "https://pub.dev"
source: hosted
version: "0.11.3"
version: "0.13.0"
args:
dependency: transitive
description:
@ -106,34 +101,42 @@ packages:
dependency: transitive
description:
name: custom_lint
sha256: "4500e88854e7581ee43586abeaf4443cb22375d6d289241a87b1aadf678d5545"
sha256: "409c485fd14f544af1da965d5a0d160ee57cd58b63eeaa7280a4f28cf5bda7f1"
url: "https://pub.dev"
source: hosted
version: "0.6.10"
version: "0.7.5"
custom_lint_builder:
dependency: "direct main"
description:
name: custom_lint_builder
sha256: "5a95eff100da256fbf086b329c17c8b49058c261cdf56d3a4157d3c31c511d78"
sha256: "107e0a43606138015777590ee8ce32f26ba7415c25b722ff0908a6f5d7a4c228"
url: "https://pub.dev"
source: hosted
version: "0.6.10"
version: "0.7.5"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "76a4046cc71d976222a078a8fd4a65e198b70545a8d690a75196dd14f08510f6"
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
url: "https://pub.dev"
source: hosted
version: "0.6.10"
version: "0.7.5"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.3.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820"
sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac"
url: "https://pub.dev"
source: hosted
version: "2.3.8"
version: "3.0.1"
file:
dependency: transitive
description:
@ -154,10 +157,10 @@ packages:
dependency: transitive
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b
url: "https://pub.dev"
source: hosted
version: "2.4.4"
version: "3.0.0"
glob:
dependency: "direct main"
description:
@ -198,14 +201,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
macros:
dependency: transitive
description:
name: macros
sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
url: "https://pub.dev"
source: hosted
version: "0.1.3-main.0"
matcher:
dependency: transitive
description:

View File

@ -5,9 +5,9 @@ environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
analyzer: ^6.0.0
analyzer_plugin: ^0.11.3
custom_lint_builder: ^0.6.4
analyzer: ^7.0.0
analyzer_plugin: ^0.13.0
custom_lint_builder: ^0.7.5
glob: ^2.1.2
dev_dependencies:

View File

@ -89,6 +89,20 @@
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
B28F36282DC3150F00B18015 /* Platform */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = Platform;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@ -175,6 +189,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
B28F36282DC3150F00B18015 /* Platform */,
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
65DD438629917FAD0047FFA8 /* BackgroundSync */,
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
@ -224,6 +239,9 @@
dependencies = (
FAC6F8992D287C890078CB2F /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
B28F36282DC3150F00B18015 /* Platform */,
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */;

View File

@ -22,6 +22,10 @@ import UIKit
BackgroundServicePlugin.registerBackgroundProcessing()
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
// Register pigeon handler
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
ImHostServiceSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ImHostServiceImpl())
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {

View File

@ -0,0 +1,152 @@
import Photos
class MediaManager {
let _defaults: UserDefaults
let _changeTokenKey = "immich:changeToken";
init(with defaults: UserDefaults = .standard) {
_defaults = defaults
}
@available(iOS 16, *)
func _getChangeToken() -> PHPersistentChangeToken? {
guard let encodedToken = _defaults.data(forKey: _changeTokenKey) else {
print("_getChangeToken: Change token not available in UserDefaults")
return nil
}
do {
let changeToken = try NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: encodedToken)
return changeToken
} catch {
print("_getChangeToken: Cannot decode the token from UserDefaults")
return nil
}
}
@available(iOS 16, *)
func _saveChangeToken(token: PHPersistentChangeToken) -> Void {
do {
let encodedToken = try NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
_defaults.set(encodedToken, forKey: _changeTokenKey)
print("_setChangeToken: Change token saved to UserDefaults")
} catch {
print("_setChangeToken: Failed to persist the token to UserDefaults: \(error)")
}
}
@available(iOS 16, *)
func checkpointSync(completion: @escaping (Result<Void, any Error>) -> Void) {
_saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
completion(.success(()))
}
@available(iOS 16, *)
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void) {
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
}
guard let storedToken = _getChangeToken() else {
// No token exists, perform the initial full sync
print("shouldUseOldSync: No token found")
completion(.success(true))
return
}
do {
_ = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
completion(.success(false))
} catch {
// fallback to using old sync when we cannot detect changes using the available token
print("shouldUseOldSync: fetchPersistentChanges failed with error (\(error))")
completion(.success(true))
}
}
@available(iOS 16, *)
func hasMediaChanges(completion: @escaping (Result<Bool, Error>) -> Void) {
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
completion(.failure(PigeonError(code: "1", message: "No photo library access", details: nil)))
return
}
let storedToken = _getChangeToken()
let currentToken = PHPhotoLibrary.shared().currentChangeToken
completion(.success(storedToken != currentToken))
}
@available(iOS 16, *)
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void) {
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
completion(.failure(PigeonError(code: "1", message: "No photo library access", details: nil)))
return
}
guard let storedToken = _getChangeToken() else {
// No token exists, definitely need a full sync
print("getMediaChanges: No token found")
completion(.failure(PigeonError(code: "2", message: "No stored change token", details: nil)))
return
}
do {
let result = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var delta = SyncDelta(updates: [], deletes: [])
for changes in result {
let details = try changes.changeDetails(for: PHObjectType.asset)
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
let deleted = details.deletedLocalIdentifiers
let options = PHFetchOptions()
options.includeHiddenAssets = true
let updatedAssets = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
var updates: [Asset] = []
updatedAssets.enumerateObjects { (asset, _, _) in
let id = asset.localIdentifier
let name = PHAssetResource.assetResources(for: asset).first?.originalFilename ?? asset.title()
let type: Int64 = Int64(asset.mediaType.rawValue)
let createdAt = asset.creationDate.map { dateFormatter.string(from: $0) }
let updatedAt = asset.modificationDate.map { dateFormatter.string(from: $0) }
let durationInSeconds: Int64 = Int64(asset.duration)
let dAsset = Asset(id: id, name: name, type: type, createdAt: createdAt, updatedAt: updatedAt, durationInSeconds: durationInSeconds, albumIds: self._getAlbumIdsForAsset(asset: asset))
updates.append(dAsset)
}
delta.updates.append(contentsOf: updates)
delta.deletes.append(contentsOf: deleted)
}
completion(.success(delta))
return
} catch {
print("getMediaChanges: Error fetching persistent changes: \(error)")
completion(.failure(PigeonError(code: "3", message: error.localizedDescription, details: nil)))
return
}
}
@available(iOS 16, *)
func _getAlbumIdsForAsset(asset: PHAsset) -> [String] {
var albumIds: [String] = []
var albums = PHAssetCollection.fetchAssetCollectionsContaining(asset, with: .album, options: nil)
albums.enumerateObjects { (album, _, _) in
albumIds.append(album.localIdentifier)
}
albums = PHAssetCollection.fetchAssetCollectionsContaining(asset, with: .smartAlbum, options: nil)
albums.enumerateObjects { (album, _, _) in
albumIds.append(album.localIdentifier)
}
return albumIds
}
}

View File

@ -0,0 +1,333 @@
// Autogenerated from Pigeon (v25.3.1), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
/// Error class for passing custom error details to Dart side.
final class PigeonError: Error {
let code: String
let message: String?
let details: Sendable?
init(code: String, message: String?, details: Sendable?) {
self.code = code
self.message = message
self.details = details
}
var localizedDescription: String {
return
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
}
}
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
return [
pigeonError.code,
pigeonError.message,
pigeonError.details,
]
}
if let flutterError = error as? FlutterError {
return [
flutterError.code,
flutterError.message,
flutterError.details,
]
}
return [
"\(error)",
"\(type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
func deepEqualsMessages(_ lhs: Any?, _ rhs: Any?) -> Bool {
let cleanLhs = nilOrValue(lhs) as Any?
let cleanRhs = nilOrValue(rhs) as Any?
switch (cleanLhs, cleanRhs) {
case (nil, nil):
return true
case (nil, _), (_, nil):
return false
case is (Void, Void):
return true
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
return cleanLhsHashable == cleanRhsHashable
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
for (index, element) in cleanLhsArray.enumerated() {
if !deepEqualsMessages(element, cleanRhsArray[index]) {
return false
}
}
return true
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
for (key, cleanLhsValue) in cleanLhsDictionary {
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
if !deepEqualsMessages(cleanLhsValue, cleanRhsDictionary[key]!) {
return false
}
}
return true
default:
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
return false
}
}
func deepHashMessages(value: Any?, hasher: inout Hasher) {
if let valueList = value as? [AnyHashable] {
for item in valueList { deepHashMessages(value: item, hasher: &hasher) }
return
}
if let valueDict = value as? [AnyHashable: AnyHashable] {
for key in valueDict.keys {
hasher.combine(key)
deepHashMessages(value: valueDict[key]!, hasher: &hasher)
}
return
}
if let hashableValue = value as? AnyHashable {
hasher.combine(hashableValue.hashValue)
}
return hasher.combine(String(describing: value))
}
/// Generated class from Pigeon that represents data sent in messages.
struct Asset: Hashable {
var id: String
var name: String
var type: Int64
var createdAt: String? = nil
var updatedAt: String? = nil
var durationInSeconds: Int64
var albumIds: [String]
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> Asset? {
let id = pigeonVar_list[0] as! String
let name = pigeonVar_list[1] as! String
let type = pigeonVar_list[2] as! Int64
let createdAt: String? = nilOrValue(pigeonVar_list[3])
let updatedAt: String? = nilOrValue(pigeonVar_list[4])
let durationInSeconds = pigeonVar_list[5] as! Int64
let albumIds = pigeonVar_list[6] as! [String]
return Asset(
id: id,
name: name,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
albumIds: albumIds
)
}
func toList() -> [Any?] {
return [
id,
name,
type,
createdAt,
updatedAt,
durationInSeconds,
albumIds,
]
}
static func == (lhs: Asset, rhs: Asset) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct SyncDelta: Hashable {
var updates: [Asset]
var deletes: [String]
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> SyncDelta? {
let updates = pigeonVar_list[0] as! [Asset]
let deletes = pigeonVar_list[1] as! [String]
return SyncDelta(
updates: updates,
deletes: deletes
)
}
func toList() -> [Any?] {
return [
updates,
deletes,
]
}
static func == (lhs: SyncDelta, rhs: SyncDelta) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
return Asset.fromList(self.readValue() as! [Any?])
case 130:
return SyncDelta.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
}
}
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? Asset {
super.writeByte(129)
super.writeValue(value.toList())
} else if let value = value as? SyncDelta {
super.writeByte(130)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
}
}
private class MessagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return MessagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return MessagesPigeonCodecWriter(data: data)
}
}
class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol ImHostService {
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void)
func hasMediaChanges(completion: @escaping (Result<Bool, Error>) -> Void)
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void)
func checkpointSync(completion: @escaping (Result<Void, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class ImHostServiceSetup {
static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared }
/// Sets up an instance of `ImHostService` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ImHostService?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
#if os(iOS)
let taskQueue = binaryMessenger.makeBackgroundTaskQueue?()
#else
let taskQueue: FlutterTaskQueue? = nil
#endif
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))
}
}
}
} else {
shouldFullSyncChannel.setMessageHandler(nil)
}
let hasMediaChangesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.hasMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasMediaChangesChannel.setMessageHandler { _, reply in
api.hasMediaChanges { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
hasMediaChangesChannel.setMessageHandler(nil)
}
let getMediaChangesChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: 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))
}
}
}
} else {
getMediaChangesChannel.setMessageHandler(nil)
}
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))
}
}
}
} else {
checkpointSyncChannel.setMessageHandler(nil)
}
}
}

View File

@ -0,0 +1,44 @@
import Photos
class ImHostServiceImpl: ImHostService {
let _mediaManager: MediaManager
init() {
_mediaManager = MediaManager()
}
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void) {
if #available(iOS 16, *) {
_mediaManager.shouldFullSync(completion: completion)
return;
} else {
// Always fall back to full sync on older iOS versions
completion(.success(true))
}
}
func hasMediaChanges(completion: @escaping (Result<Bool, Error>) -> Void) {
if #available(iOS 16, *) {
_mediaManager.hasMediaChanges(completion: completion)
} else {
completion(.failure(PigeonError(code: "-1", message: "Not supported", details: nil)))
}
}
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void) {
if #available(iOS 16, *) {
_mediaManager.getMediaChanges(completion: completion)
} else {
completion(.failure(PigeonError(code: "-1", message: "Not supported", details: nil)))
}
}
func checkpointSync(completion: @escaping (Result<Void, any Error>) -> Void) {
if #available(iOS 16, *) {
_mediaManager.checkpointSync(completion: completion)
} else {
completion(.success(()))
}
}
}

View File

@ -1,6 +1,7 @@
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/platform/messages.g.dart';
abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
Future<void> insert(LocalAlbum album, Iterable<LocalAsset> assets);
@ -13,6 +14,10 @@ abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
Future<void> update(LocalAlbum album);
Future<void> updateAll(Iterable<LocalAlbum> albums);
Future<void> handleSyncDelta(SyncDelta delta);
Future<void> delete(String albumId);
Future<void> removeAssets(String albumId, Iterable<String> assetIds);

View File

@ -2,5 +2,5 @@ import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
abstract interface class ILocalAssetRepository implements IDatabaseRepository {
Future<LocalAsset> get(String assetId);
Future<LocalAsset> get(String id);
}

View File

@ -4,28 +4,50 @@ import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/platform/messages.g.dart' as platform;
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/nullable_value.dart';
import 'package:logging/logging.dart';
class DeviceSyncService {
final IAlbumMediaRepository _albumMediaRepository;
final ILocalAlbumRepository _localAlbumRepository;
final ILocalAssetRepository _localAssetRepository;
final platform.ImHostService _hostService;
final Logger _log = Logger("SyncService");
DeviceSyncService({
required IAlbumMediaRepository albumMediaRepository,
required ILocalAlbumRepository localAlbumRepository,
required ILocalAssetRepository localAssetRepository,
required platform.ImHostService hostService,
}) : _albumMediaRepository = albumMediaRepository,
_localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository;
_hostService = hostService;
Future<void> sync() async {
try {
if (await _hostService.shouldFullSync()) {
_log.fine("Cannot use partial sync. Performing full sync");
return await fullSync();
}
if (!await _hostService.hasMediaChanges()) {
_log.fine("No media changes detected. Skipping sync");
return;
}
final deviceAlbums = await _albumMediaRepository.getAll();
await _localAlbumRepository.updateAll(deviceAlbums);
final delta = await _hostService.getMediaChanges();
await _localAlbumRepository.handleSyncDelta(delta);
await _hostService.checkpointSync();
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
}
}
Future<void> fullSync() async {
try {
final Stopwatch stopwatch = Stopwatch()..start();
// The deviceAlbums will not have the updatedAt field
@ -47,6 +69,7 @@ class DeviceSyncService {
onlySecond: addAlbum,
);
_hostService.checkpointSync();
stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} catch (e, s) {
@ -57,17 +80,12 @@ class DeviceSyncService {
Future<void> addAlbum(LocalAlbum newAlbum) async {
try {
_log.info("Adding device album ${newAlbum.name}");
final deviceAlbum = await _albumMediaRepository.refresh(newAlbum.id);
final album = await _albumMediaRepository.refresh(newAlbum.id);
final assets = deviceAlbum.assetCount > 0
? await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id)
final assets = album.assetCount > 0
? await _albumMediaRepository.getAssetsForAlbum(album.id)
: <LocalAsset>[];
final album = deviceAlbum.copyWith(
// The below assumes the list is already sorted by createdDate from the filter
thumbnailId: NullableValue.valueOrEmpty(assets.firstOrNull?.id),
);
await _localAlbumRepository.insert(album, assets);
_log.fine("Successfully added device album ${album.name}");
} catch (e, s) {
@ -110,7 +128,7 @@ class DeviceSyncService {
}
// Slower path - full sync
return await fullSync(dbAlbum, deviceAlbum);
return await fullDiff(dbAlbum, deviceAlbum);
} catch (e, s) {
_log.warning("Error while diff device album", e, s);
}
@ -155,25 +173,8 @@ class DeviceSyncService {
return false;
}
String? thumbnailId = dbAlbum.thumbnailId;
if (thumbnailId == null || newAssets.isNotEmpty) {
if (thumbnailId == null) {
thumbnailId = newAssets.firstOrNull?.id;
} else if (newAssets.isNotEmpty) {
// The below assumes the list is already sorted by createdDate from the filter
final oldThumbAsset = await _localAssetRepository.get(thumbnailId);
if (oldThumbAsset.createdAt
.isBefore(newAssets.firstOrNull!.createdAt)) {
thumbnailId = newAssets.firstOrNull?.id;
}
}
}
await _updateAlbum(
deviceAlbum.copyWith(
thumbnailId: NullableValue.valueOrEmpty(thumbnailId),
backupSelection: dbAlbum.backupSelection,
),
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
assetsToUpsert: newAssets,
);
@ -187,7 +188,7 @@ class DeviceSyncService {
@visibleForTesting
// The [deviceAlbum] is expected to be refreshed before calling this method
// with modified time and asset count
Future<bool> fullSync(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
Future<bool> fullDiff(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
try {
final assetsInDevice = deviceAlbum.assetCount > 0
? await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id)
@ -201,23 +202,13 @@ class DeviceSyncService {
"Device album ${deviceAlbum.name} is empty. Removing assets from DB.",
);
await _updateAlbum(
deviceAlbum.copyWith(
// Clear thumbnail for empty album
thumbnailId: const NullableValue.empty(),
backupSelection: dbAlbum.backupSelection,
),
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
assetIdsToDelete: assetsInDb.map((a) => a.id),
);
return true;
}
// The below assumes the list is already sorted by createdDate from the filter
String? thumbnailId = assetsInDevice.isNotEmpty
? assetsInDevice.firstOrNull?.id
: dbAlbum.thumbnailId;
final updatedDeviceAlbum = deviceAlbum.copyWith(
thumbnailId: NullableValue.valueOrEmpty(thumbnailId),
backupSelection: dbAlbum.backupSelection,
);

View File

@ -1,7 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class LocalAlbumEntity extends Table with DriftDefaultsMixin {
@ -10,10 +9,8 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin {
TextColumn get id => text()();
TextColumn get name => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get thumbnailId => text()
.nullable()
.references(LocalAssetEntity, #localId, onDelete: KeyAction.setNull)();
IntColumn get backupSelection => intEnum<BackupSelection>()();
BoolColumn get marker_ => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
@ -26,7 +23,6 @@ extension LocalAlbumEntityX on LocalAlbumEntityData {
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
thumbnailId: thumbnailId,
backupSelection: backupSelection,
);
}

View File

@ -7,59 +7,24 @@ import 'package:immich_mobile/domain/models/local_album.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'
as i3;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i5;
import 'package:drift/internal/modular.dart' as i6;
typedef $$LocalAlbumEntityTableCreateCompanionBuilder
= i1.LocalAlbumEntityCompanion Function({
required String id,
required String name,
i0.Value<DateTime> updatedAt,
i0.Value<String?> thumbnailId,
required i2.BackupSelection backupSelection,
i0.Value<bool> marker_,
});
typedef $$LocalAlbumEntityTableUpdateCompanionBuilder
= i1.LocalAlbumEntityCompanion Function({
i0.Value<String> id,
i0.Value<String> name,
i0.Value<DateTime> updatedAt,
i0.Value<String?> thumbnailId,
i0.Value<i2.BackupSelection> backupSelection,
i0.Value<bool> marker_,
});
final class $$LocalAlbumEntityTableReferences extends i0.BaseReferences<
i0.GeneratedDatabase, i1.$LocalAlbumEntityTable, i1.LocalAlbumEntityData> {
$$LocalAlbumEntityTableReferences(
super.$_db, super.$_table, super.$_typedResult);
static i5.$LocalAssetEntityTable _thumbnailIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$LocalAssetEntityTable>('local_asset_entity')
.createAlias(i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$LocalAlbumEntityTable>('local_album_entity')
.thumbnailId,
i6.ReadDatabaseContainer(db)
.resultSet<i5.$LocalAssetEntityTable>('local_asset_entity')
.localId));
i5.$$LocalAssetEntityTableProcessedTableManager? get thumbnailId {
final $_column = $_itemColumn<String>('thumbnail_id');
if ($_column == null) return null;
final manager = i5
.$$LocalAssetEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer($_db)
.resultSet<i5.$LocalAssetEntityTable>('local_asset_entity'))
.filter((f) => f.localId.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_thumbnailIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
}
class $$LocalAlbumEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable> {
$$LocalAlbumEntityTableFilterComposer({
@ -83,27 +48,8 @@ class $$LocalAlbumEntityTableFilterComposer
column: $table.backupSelection,
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
i5.$$LocalAssetEntityTableFilterComposer get thumbnailId {
final i5.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.thumbnailId,
referencedTable: i6.ReadDatabaseContainer($db)
.resultSet<i5.$LocalAssetEntityTable>('local_asset_entity'),
getReferencedColumn: (t) => t.localId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i5.$$LocalAssetEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer($db)
.resultSet<i5.$LocalAssetEntityTable>('local_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
column: $table.marker_, builder: (column) => i0.ColumnFilters(column));
}
class $$LocalAlbumEntityTableOrderingComposer
@ -129,29 +75,8 @@ class $$LocalAlbumEntityTableOrderingComposer
column: $table.backupSelection,
builder: (column) => i0.ColumnOrderings(column));
i5.$$LocalAssetEntityTableOrderingComposer get thumbnailId {
final i5.$$LocalAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.thumbnailId,
referencedTable: i6.ReadDatabaseContainer($db)
.resultSet<i5.$LocalAssetEntityTable>('local_asset_entity'),
getReferencedColumn: (t) => t.localId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i5.$$LocalAssetEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer($db)
.resultSet<i5.$LocalAssetEntityTable>(
'local_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
column: $table.marker_, builder: (column) => i0.ColumnOrderings(column));
}
class $$LocalAlbumEntityTableAnnotationComposer
@ -176,29 +101,8 @@ class $$LocalAlbumEntityTableAnnotationComposer
get backupSelection => $composableBuilder(
column: $table.backupSelection, builder: (column) => column);
i5.$$LocalAssetEntityTableAnnotationComposer get thumbnailId {
final i5.$$LocalAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.thumbnailId,
referencedTable: i6.ReadDatabaseContainer($db)
.resultSet<i5.$LocalAssetEntityTable>('local_asset_entity'),
getReferencedColumn: (t) => t.localId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i5.$$LocalAssetEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer($db)
.resultSet<i5.$LocalAssetEntityTable>(
'local_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i0.GeneratedColumn<bool> get marker_ =>
$composableBuilder(column: $table.marker_, builder: (column) => column);
}
class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
@ -210,9 +114,13 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
i1.$$LocalAlbumEntityTableAnnotationComposer,
$$LocalAlbumEntityTableCreateCompanionBuilder,
$$LocalAlbumEntityTableUpdateCompanionBuilder,
(i1.LocalAlbumEntityData, i1.$$LocalAlbumEntityTableReferences),
(
i1.LocalAlbumEntityData,
i0.BaseReferences<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData>
),
i1.LocalAlbumEntityData,
i0.PrefetchHooks Function({bool thumbnailId})> {
i0.PrefetchHooks Function()> {
$$LocalAlbumEntityTableTableManager(
i0.GeneratedDatabase db, i1.$LocalAlbumEntityTable table)
: super(i0.TableManagerState(
@ -229,73 +137,35 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
i0.Value<String> id = const i0.Value.absent(),
i0.Value<String> name = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
i0.Value<String?> thumbnailId = const i0.Value.absent(),
i0.Value<i2.BackupSelection> backupSelection =
const i0.Value.absent(),
i0.Value<bool> marker_ = const i0.Value.absent(),
}) =>
i1.LocalAlbumEntityCompanion(
id: id,
name: name,
updatedAt: updatedAt,
thumbnailId: thumbnailId,
backupSelection: backupSelection,
marker_: marker_,
),
createCompanionCallback: ({
required String id,
required String name,
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
i0.Value<String?> thumbnailId = const i0.Value.absent(),
required i2.BackupSelection backupSelection,
i0.Value<bool> marker_ = const i0.Value.absent(),
}) =>
i1.LocalAlbumEntityCompanion.insert(
id: id,
name: name,
updatedAt: updatedAt,
thumbnailId: thumbnailId,
backupSelection: backupSelection,
marker_: marker_,
),
withReferenceMapper: (p0) => p0
.map((e) => (
e.readTable(table),
i1.$$LocalAlbumEntityTableReferences(db, table, e)
))
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: ({thumbnailId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins: <
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic>>(state) {
if (thumbnailId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.thumbnailId,
referencedTable: i1.$$LocalAlbumEntityTableReferences
._thumbnailIdTable(db),
referencedColumn: i1.$$LocalAlbumEntityTableReferences
._thumbnailIdTable(db)
.localId,
) as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
prefetchHooksCallback: null,
));
}
@ -308,9 +178,13 @@ typedef $$LocalAlbumEntityTableProcessedTableManager = i0.ProcessedTableManager<
i1.$$LocalAlbumEntityTableAnnotationComposer,
$$LocalAlbumEntityTableCreateCompanionBuilder,
$$LocalAlbumEntityTableUpdateCompanionBuilder,
(i1.LocalAlbumEntityData, i1.$$LocalAlbumEntityTableReferences),
(
i1.LocalAlbumEntityData,
i0.BaseReferences<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData>
),
i1.LocalAlbumEntityData,
i0.PrefetchHooks Function({bool thumbnailId})>;
i0.PrefetchHooks Function()>;
class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
with i0.TableInfo<$LocalAlbumEntityTable, i1.LocalAlbumEntityData> {
@ -337,15 +211,6 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i4.currentDateAndTime);
static const i0.VerificationMeta _thumbnailIdMeta =
const i0.VerificationMeta('thumbnailId');
@override
late final i0.GeneratedColumn<String> thumbnailId =
i0.GeneratedColumn<String>('thumbnail_id', aliasedName, true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES local_asset_entity (local_id) ON DELETE SET NULL'));
@override
late final i0.GeneratedColumnWithTypeConverter<i2.BackupSelection, int>
backupSelection = i0.GeneratedColumn<int>(
@ -353,9 +218,19 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
type: i0.DriftSqlType.int, requiredDuringInsert: true)
.withConverter<i2.BackupSelection>(
i1.$LocalAlbumEntityTable.$converterbackupSelection);
static const i0.VerificationMeta _marker_Meta =
const i0.VerificationMeta('marker_');
@override
late final i0.GeneratedColumn<bool> marker_ = i0.GeneratedColumn<bool>(
'marker', aliasedName, false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints:
i0.GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))'),
defaultValue: const i4.Constant(false));
@override
List<i0.GeneratedColumn> get $columns =>
[id, name, updatedAt, thumbnailId, backupSelection];
[id, name, updatedAt, backupSelection, marker_];
@override
String get aliasedName => _alias ?? actualTableName;
@override
@ -382,11 +257,9 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
context.handle(_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
}
if (data.containsKey('thumbnail_id')) {
context.handle(
_thumbnailIdMeta,
thumbnailId.isAcceptableOrUnknown(
data['thumbnail_id']!, _thumbnailIdMeta));
if (data.containsKey('marker')) {
context.handle(_marker_Meta,
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta));
}
return context;
}
@ -404,11 +277,11 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
.read(i0.DriftSqlType.string, data['${effectivePrefix}name'])!,
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!,
thumbnailId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}thumbnail_id']),
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
.fromSql(attachedDatabase.typeMapping.read(i0.DriftSqlType.int,
data['${effectivePrefix}backup_selection'])!),
marker_: attachedDatabase.typeMapping
.read(i0.DriftSqlType.bool, data['${effectivePrefix}marker'])!,
);
}
@ -432,28 +305,26 @@ class LocalAlbumEntityData extends i0.DataClass
final String id;
final String name;
final DateTime updatedAt;
final String? thumbnailId;
final i2.BackupSelection backupSelection;
final bool marker_;
const LocalAlbumEntityData(
{required this.id,
required this.name,
required this.updatedAt,
this.thumbnailId,
required this.backupSelection});
required this.backupSelection,
required this.marker_});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<String>(id);
map['name'] = i0.Variable<String>(name);
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
if (!nullToAbsent || thumbnailId != null) {
map['thumbnail_id'] = i0.Variable<String>(thumbnailId);
}
{
map['backup_selection'] = i0.Variable<int>(i1
.$LocalAlbumEntityTable.$converterbackupSelection
.toSql(backupSelection));
}
map['marker'] = i0.Variable<bool>(marker_);
return map;
}
@ -464,9 +335,9 @@ class LocalAlbumEntityData extends i0.DataClass
id: serializer.fromJson<String>(json['id']),
name: serializer.fromJson<String>(json['name']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
thumbnailId: serializer.fromJson<String?>(json['thumbnailId']),
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
.fromJson(serializer.fromJson<int>(json['backupSelection'])),
marker_: serializer.fromJson<bool>(json['marker_']),
);
}
@override
@ -476,10 +347,10 @@ class LocalAlbumEntityData extends i0.DataClass
'id': serializer.toJson<String>(id),
'name': serializer.toJson<String>(name),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
'thumbnailId': serializer.toJson<String?>(thumbnailId),
'backupSelection': serializer.toJson<int>(i1
.$LocalAlbumEntityTable.$converterbackupSelection
.toJson(backupSelection)),
'marker_': serializer.toJson<bool>(marker_),
};
}
@ -487,25 +358,24 @@ class LocalAlbumEntityData extends i0.DataClass
{String? id,
String? name,
DateTime? updatedAt,
i0.Value<String?> thumbnailId = const i0.Value.absent(),
i2.BackupSelection? backupSelection}) =>
i2.BackupSelection? backupSelection,
bool? marker_}) =>
i1.LocalAlbumEntityData(
id: id ?? this.id,
name: name ?? this.name,
updatedAt: updatedAt ?? this.updatedAt,
thumbnailId: thumbnailId.present ? thumbnailId.value : this.thumbnailId,
backupSelection: backupSelection ?? this.backupSelection,
marker_: marker_ ?? this.marker_,
);
LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) {
return LocalAlbumEntityData(
id: data.id.present ? data.id.value : this.id,
name: data.name.present ? data.name.value : this.name,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
thumbnailId:
data.thumbnailId.present ? data.thumbnailId.value : this.thumbnailId,
backupSelection: data.backupSelection.present
? data.backupSelection.value
: this.backupSelection,
marker_: data.marker_.present ? data.marker_.value : this.marker_,
);
}
@ -515,15 +385,15 @@ class LocalAlbumEntityData extends i0.DataClass
..write('id: $id, ')
..write('name: $name, ')
..write('updatedAt: $updatedAt, ')
..write('thumbnailId: $thumbnailId, ')
..write('backupSelection: $backupSelection')
..write('backupSelection: $backupSelection, ')
..write('marker_: $marker_')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, name, updatedAt, thumbnailId, backupSelection);
Object.hash(id, name, updatedAt, backupSelection, marker_);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@ -531,8 +401,8 @@ class LocalAlbumEntityData extends i0.DataClass
other.id == this.id &&
other.name == this.name &&
other.updatedAt == this.updatedAt &&
other.thumbnailId == this.thumbnailId &&
other.backupSelection == this.backupSelection);
other.backupSelection == this.backupSelection &&
other.marker_ == this.marker_);
}
class LocalAlbumEntityCompanion
@ -540,21 +410,21 @@ class LocalAlbumEntityCompanion
final i0.Value<String> id;
final i0.Value<String> name;
final i0.Value<DateTime> updatedAt;
final i0.Value<String?> thumbnailId;
final i0.Value<i2.BackupSelection> backupSelection;
final i0.Value<bool> marker_;
const LocalAlbumEntityCompanion({
this.id = const i0.Value.absent(),
this.name = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
this.thumbnailId = const i0.Value.absent(),
this.backupSelection = const i0.Value.absent(),
this.marker_ = const i0.Value.absent(),
});
LocalAlbumEntityCompanion.insert({
required String id,
required String name,
this.updatedAt = const i0.Value.absent(),
this.thumbnailId = const i0.Value.absent(),
required i2.BackupSelection backupSelection,
this.marker_ = const i0.Value.absent(),
}) : id = i0.Value(id),
name = i0.Value(name),
backupSelection = i0.Value(backupSelection);
@ -562,15 +432,15 @@ class LocalAlbumEntityCompanion
i0.Expression<String>? id,
i0.Expression<String>? name,
i0.Expression<DateTime>? updatedAt,
i0.Expression<String>? thumbnailId,
i0.Expression<int>? backupSelection,
i0.Expression<bool>? marker_,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (name != null) 'name': name,
if (updatedAt != null) 'updated_at': updatedAt,
if (thumbnailId != null) 'thumbnail_id': thumbnailId,
if (backupSelection != null) 'backup_selection': backupSelection,
if (marker_ != null) 'marker': marker_,
});
}
@ -578,14 +448,14 @@ class LocalAlbumEntityCompanion
{i0.Value<String>? id,
i0.Value<String>? name,
i0.Value<DateTime>? updatedAt,
i0.Value<String?>? thumbnailId,
i0.Value<i2.BackupSelection>? backupSelection}) {
i0.Value<i2.BackupSelection>? backupSelection,
i0.Value<bool>? marker_}) {
return i1.LocalAlbumEntityCompanion(
id: id ?? this.id,
name: name ?? this.name,
updatedAt: updatedAt ?? this.updatedAt,
thumbnailId: thumbnailId ?? this.thumbnailId,
backupSelection: backupSelection ?? this.backupSelection,
marker_: marker_ ?? this.marker_,
);
}
@ -601,14 +471,14 @@ class LocalAlbumEntityCompanion
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
if (thumbnailId.present) {
map['thumbnail_id'] = i0.Variable<String>(thumbnailId.value);
}
if (backupSelection.present) {
map['backup_selection'] = i0.Variable<int>(i1
.$LocalAlbumEntityTable.$converterbackupSelection
.toSql(backupSelection.value));
}
if (marker_.present) {
map['marker'] = i0.Variable<bool>(marker_.value);
}
return map;
}
@ -618,8 +488,8 @@ class LocalAlbumEntityCompanion
..write('id: $id, ')
..write('name: $name, ')
..write('updatedAt: $updatedAt, ')
..write('thumbnailId: $thumbnailId, ')
..write('backupSelection: $backupSelection')
..write('backupSelection: $backupSelection, ')
..write('marker_: $marker_')
..write(')'))
.toString();
}

View File

@ -41,12 +41,10 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
@override
Future<List<LocalAlbum>> getAll() {
final filter = AdvancedCustomFilter(
orderBy: [OrderByItem.asc(CustomColumns.base.id)],
);
return PhotoManager.getAssetPathList(hasAll: true, filterOption: filter)
.then((e) {
return PhotoManager.getAssetPathList(
hasAll: true,
filterOption: AdvancedCustomFilter(),
).then((e) {
if (_platform.isAndroid) {
e.removeWhere((a) => a.isAll);
}

View File

@ -7,9 +7,9 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift
as i2;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i6;
@ -26,10 +26,10 @@ abstract class $Drift extends i0.GeneratedDatabase {
i2.$UserMetadataEntityTable(this);
late final i3.$PartnerEntityTable partnerEntity =
i3.$PartnerEntityTable(this);
late final i4.$LocalAssetEntityTable localAssetEntity =
i4.$LocalAssetEntityTable(this);
late final i5.$LocalAlbumEntityTable localAlbumEntity =
i5.$LocalAlbumEntityTable(this);
late final i4.$LocalAlbumEntityTable localAlbumEntity =
i4.$LocalAlbumEntityTable(this);
late final i5.$LocalAssetEntityTable localAssetEntity =
i5.$LocalAssetEntityTable(this);
late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
i6.$LocalAlbumAssetEntityTable(this);
late final i7.$RemoteAssetEntityTable remoteAssetEntity =
@ -43,12 +43,12 @@ abstract class $Drift extends i0.GeneratedDatabase {
userEntity,
userMetadataEntity,
partnerEntity,
localAssetEntity,
localAlbumEntity,
localAssetEntity,
localAlbumAssetEntity,
remoteAssetEntity,
exifEntity,
i4.localAssetChecksum,
i5.localAssetChecksum,
i7.remoteAssetChecksum
];
@override
@ -77,13 +77,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('partner_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('local_asset_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('local_album_entity', kind: i0.UpdateKind.update),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('local_asset_entity',
limitUpdateKind: i0.UpdateKind.delete),
@ -130,10 +123,10 @@ class $DriftManager {
i2.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i3.$$PartnerEntityTableTableManager get partnerEntity =>
i3.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i4.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i4.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i5.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i5.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i4.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i4.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i5.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i5.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i7.$$RemoteAssetEntityTableTableManager get remoteAssetEntity =>

View File

@ -8,6 +8,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.d
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/platform/messages.g.dart' as platform;
import 'package:platform/platform.dart';
class DriftLocalAlbumRepository extends DriftDatabaseRepository
@ -65,7 +66,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
transaction(() async {
await _upsertAssets(assets);
// Needs to be after asset upsert to link the thumbnail
await _upsertAlbum(localAlbum);
await update(localAlbum);
await _linkAssetsToAlbum(localAlbum.id, assets);
});
@ -112,7 +113,46 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
}
@override
Future<void> update(LocalAlbum localAlbum) => _upsertAlbum(localAlbum);
Future<void> update(LocalAlbum localAlbum) {
final companion = LocalAlbumEntityCompanion.insert(
id: localAlbum.id,
name: localAlbum.name,
updatedAt: Value(localAlbum.updatedAt),
backupSelection: localAlbum.backupSelection,
);
return _db.localAlbumEntity
.insertOne(companion, onConflict: DoUpdate((_) => companion));
}
@override
Future<void> updateAll(Iterable<LocalAlbum> albums) {
return _db.transaction(() async {
await _db.localAlbumEntity
.update()
.write(const LocalAlbumEntityCompanion(marker_: Value(false)));
await _db.batch((batch) {
for (final album in albums) {
final companion = LocalAlbumEntityCompanion.insert(
id: album.id,
name: album.name,
updatedAt: Value(album.updatedAt),
backupSelection: album.backupSelection,
marker_: const Value(true),
);
batch.insert(
_db.localAlbumEntity,
companion,
onConflict: DoUpdate((_) => companion),
);
}
});
await _db.localAlbumEntity.deleteWhere((f) => f.marker_.equals(false));
});
}
@override
Future<List<LocalAsset>> getAssetsForAlbum(String albumId) {
@ -132,17 +172,30 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
.get();
}
Future<void> _upsertAlbum(LocalAlbum localAlbum) {
final companion = LocalAlbumEntityCompanion.insert(
id: localAlbum.id,
name: localAlbum.name,
updatedAt: Value(localAlbum.updatedAt),
thumbnailId: Value.absentIfNull(localAlbum.thumbnailId),
backupSelection: localAlbum.backupSelection,
);
@override
Future<void> handleSyncDelta(platform.SyncDelta delta) {
return _db.transaction(() async {
await _deleteAssets(delta.deletes);
return _db.localAlbumEntity
.insertOne(companion, onConflict: DoUpdate((_) => companion));
await _upsertAssets(delta.updates.map((a) => a.toLocalAsset()));
await _db.batch((batch) {
batch.deleteWhere(
_db.localAlbumAssetEntity,
(f) => f.assetId.isIn(delta.updates.map((a) => a.id)),
);
for (final asset in delta.updates) {
batch.insertAll(
_db.localAlbumAssetEntity,
asset.albumIds.map(
(albumId) => LocalAlbumAssetEntityCompanion.insert(
assetId: asset.id,
albumId: albumId,
),
),
);
}
});
});
}
Future<void> _linkAssetsToAlbum(
@ -240,3 +293,18 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
);
}
}
extension on platform.Asset {
LocalAsset toLocalAsset() {
return LocalAsset(
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!),
durationInSeconds: durationInSeconds,
);
}
}

View File

@ -9,8 +9,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository
const DriftLocalAssetRepository(this._db) : super(_db);
@override
Future<LocalAsset> get(String assetId) => _db.managers.localAssetEntity
.filter((f) => f.localId(assetId))
Future<LocalAsset> get(String id) => _db.managers.localAssetEntity
.filter((f) => f.localId(id))
.map((a) => a.toDto())
.getSingle();
}

View File

@ -0,0 +1,55 @@
// ignore: depend_on_referenced_packages
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/messages.g.dart',
swiftOut: 'ios/Runner/Platform/Messages.g.swift',
swiftOptions: SwiftOptions(),
kotlinOut:
'android/app/src/main/kotlin/app/alextran/immich/platform/messages.g.kt',
kotlinOptions: KotlinOptions(),
dartOptions: DartOptions(),
),
)
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;
final int durationInSeconds;
final List<String> albumIds;
const Asset({
required this.id,
required this.name,
required this.type,
required this.createdAt,
required this.updatedAt,
required this.durationInSeconds,
required this.albumIds,
});
}
class SyncDelta {
SyncDelta({this.updates = const [], this.deletes = const []});
List<Asset> updates;
List<String> deletes;
}
@HostApi()
abstract class ImHostService {
@async
bool shouldFullSync();
@async
bool hasMediaChanges();
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
SyncDelta getMediaChanges();
@async
void checkpointSync();
}

310
mobile/lib/platform/messages.g.dart generated Normal file
View File

@ -0,0 +1,310 @@
// Autogenerated from Pigeon (v25.3.1), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
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]));
}
if (a is Map && b is Map) {
return a.length == b.length &&
a.entries.every((MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]));
}
return a == b;
}
class Asset {
Asset({
required this.id,
required this.name,
required this.type,
this.createdAt,
this.updatedAt,
required this.durationInSeconds,
required this.albumIds,
});
String id;
String name;
int type;
String? createdAt;
String? updatedAt;
int durationInSeconds;
List<String> albumIds;
List<Object?> _toList() {
return <Object?>[
id,
name,
type,
createdAt,
updatedAt,
durationInSeconds,
albumIds,
];
}
Object encode() {
return _toList();
}
static Asset decode(Object result) {
result as List<Object?>;
return 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?,
durationInSeconds: result[5]! as int,
albumIds: (result[6] as List<Object?>?)!.cast<String>(),
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! Asset || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList());
}
class SyncDelta {
SyncDelta({
this.updates = const [],
this.deletes = const [],
});
List<Asset> updates;
List<String> deletes;
List<Object?> _toList() {
return <Object?>[
updates,
deletes,
];
}
Object encode() {
return _toList();
}
static SyncDelta decode(Object result) {
result as List<Object?>;
return SyncDelta(
updates: (result[0] as List<Object?>?)!.cast<Asset>(),
deletes: (result[1] as List<Object?>?)!.cast<String>(),
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SyncDelta || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList());
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is Asset) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else if (value is SyncDelta) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
return Asset.decode(readValue(buffer)!);
case 130:
return SyncDelta.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
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 = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix =
messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<bool> shouldFullSync() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
Future<bool> hasMediaChanges() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ImHostService.hasMediaChanges$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
Future<SyncDelta> getMediaChanges() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as SyncDelta?)!;
}
}
Future<void> checkpointSync() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
}

View File

@ -0,0 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/messages.g.dart';
final platformMessagesImpl = Provider<ImHostService>((_) => ImHostService());

View File

@ -5,15 +5,15 @@ import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.da
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
final deviceSyncServiceProvider = Provider(
(ref) => DeviceSyncService(
albumMediaRepository: ref.watch(albumMediaRepositoryProvider),
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetProvider),
hostService: ref.watch(platformMessagesImpl),
),
);

File diff suppressed because it is too large Load Diff

View File

@ -5,31 +5,26 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57
url: "https://pub.dev"
source: hosted
version: "76.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.3"
version: "80.0.0"
analyzer:
dependency: "direct overridden"
dependency: transitive
description:
name: analyzer
sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e"
url: "https://pub.dev"
source: hosted
version: "6.11.0"
version: "7.3.0"
analyzer_plugin:
dependency: "direct overridden"
dependency: transitive
description:
name: analyzer_plugin
sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4
url: "https://pub.dev"
source: hosted
version: "0.11.3"
version: "0.13.0"
ansicolor:
dependency: transitive
description:
@ -74,10 +69,10 @@ packages:
dependency: "direct dev"
description:
name: auto_route_generator
sha256: c9086eb07271e51b44071ad5cff34e889f3156710b964a308c2ab590769e79e6
sha256: c2e359d8932986d4d1bcad7a428143f81384ce10fef8d4aa5bc29e1f83766a46
url: "https://pub.dev"
source: hosted
version: "9.0.0"
version: "9.3.1"
background_downloader:
dependency: "direct main"
description:
@ -322,34 +317,42 @@ packages:
dependency: "direct dev"
description:
name: custom_lint
sha256: "4500e88854e7581ee43586abeaf4443cb22375d6d289241a87b1aadf678d5545"
sha256: "409c485fd14f544af1da965d5a0d160ee57cd58b63eeaa7280a4f28cf5bda7f1"
url: "https://pub.dev"
source: hosted
version: "0.6.10"
version: "0.7.5"
custom_lint_builder:
dependency: transitive
description:
name: custom_lint_builder
sha256: "5a95eff100da256fbf086b329c17c8b49058c261cdf56d3a4157d3c31c511d78"
sha256: "107e0a43606138015777590ee8ce32f26ba7415c25b722ff0908a6f5d7a4c228"
url: "https://pub.dev"
source: hosted
version: "0.6.10"
version: "0.7.5"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "76a4046cc71d976222a078a8fd4a65e198b70545a8d690a75196dd14f08510f6"
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
url: "https://pub.dev"
source: hosted
version: "0.6.10"
version: "0.7.5"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.3.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820"
sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac"
url: "https://pub.dev"
source: hosted
version: "2.3.8"
version: "3.0.1"
dartx:
dependency: transitive
description:
@ -675,10 +678,10 @@ packages:
dependency: transitive
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b
url: "https://pub.dev"
source: hosted
version: "2.4.4"
version: "3.0.0"
frontend_server_client:
dependency: transitive
description:
@ -923,10 +926,11 @@ packages:
isar_generator:
dependency: "direct dev"
description:
name: isar_generator
sha256: "484e73d3b7e81dbd816852fe0b9497333118a9aeb646fd2d349a62cc8980ffe1"
url: "https://pub.isar-community.dev"
source: hosted
path: "packages/isar_generator"
ref: v3
resolved-ref: ad574f60ed6f39d2995cd16fc7dc3de9a646ef30
url: "https://github.com/callumw-k/isar"
source: git
version: "3.1.8"
js:
dependency: transitive
@ -984,14 +988,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
macros:
dependency: transitive
description:
name: macros
sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
url: "https://pub.dev"
source: hosted
version: "0.1.3-main.0"
maplibre_gl:
dependency: "direct main"
description:
@ -1033,7 +1029,7 @@ packages:
source: hosted
version: "0.11.1"
meta:
dependency: "direct overridden"
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
@ -1264,6 +1260,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pigeon:
dependency: "direct dev"
description:
name: pigeon
sha256: "3e4e6258f22760fa11f86d2a5202fb3f8367cb361d33bd9a93de85a7959e9976"
url: "https://pub.dev"
source: hosted
version: "25.3.1"
platform:
dependency: "direct main"
description:
@ -1348,10 +1352,10 @@ packages:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: "0dcb0af32d561f8fa000c6a6d95633c9fb08ea8a8df46e3f9daca59f11218167"
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
version: "0.5.10"
riverpod_annotation:
dependency: "direct main"
description:
@ -1364,18 +1368,18 @@ packages:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: "851aedac7ad52693d12af3bf6d92b1626d516ed6b764eb61bf19e968b5e0b931"
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "2.6.5"
riverpod_lint:
dependency: "direct dev"
description:
name: riverpod_lint
sha256: "0684c21a9a4582c28c897d55c7b611fa59a351579061b43f8c92c005804e63a8"
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "2.6.5"
rxdart:
dependency: transitive
description:
@ -1537,10 +1541,10 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "2.0.0"
source_span:
dependency: transitive
description:

View File

@ -82,11 +82,6 @@ dependencies:
drift: ^2.23.1
drift_flutter: ^0.2.4
dependency_overrides:
analyzer: ^6.0.0
meta: ^1.11.0
analyzer_plugin: ^0.11.3
dev_dependencies:
flutter_test:
sdk: flutter
@ -96,11 +91,13 @@ dev_dependencies:
flutter_launcher_icons: ^0.14.3
flutter_native_splash: ^2.4.5
isar_generator:
version: *isar_version
hosted: https://pub.isar-community.dev/
git:
url: https://github.com/callumw-k/isar
ref: v3
path: packages/isar_generator/
integration_test:
sdk: flutter
custom_lint: ^0.6.4
custom_lint: ^0.7.5
riverpod_lint: ^2.6.1
riverpod_generator: ^2.6.1
mocktail: ^1.0.4
@ -110,6 +107,8 @@ dev_dependencies:
file: ^7.0.1 # for MemoryFileSystem
# Drift generator
drift_dev: ^2.23.1
# Type safe platform code
pigeon: ^25.3.1
flutter:
uses-material-design: true

View File

@ -1,6 +1,7 @@
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/messages.g.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreService extends Mock implements StoreService {}
@ -8,3 +9,5 @@ class MockStoreService extends Mock implements StoreService {}
class MockUserService extends Mock implements UserService {}
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockHostService extends Mock implements ImHostService {}

View File

@ -2,21 +2,21 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/domain/services/device_sync.service.dart';
import 'package:immich_mobile/utils/nullable_value.dart';
import 'package:immich_mobile/platform/messages.g.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/local_album.stub.dart';
import '../../fixtures/local_asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../service.mock.dart';
void main() {
late IAlbumMediaRepository mockAlbumMediaRepo;
late ILocalAlbumRepository mockLocalAlbumRepo;
late ILocalAssetRepository mockLocalAssetRepo;
late ImHostService mockHostService;
late DeviceSyncService sut;
Future<T> mockTransaction<T>(Future<T> Function() action) => action();
@ -24,12 +24,12 @@ void main() {
setUp(() {
mockAlbumMediaRepo = MockAlbumMediaRepository();
mockLocalAlbumRepo = MockLocalAlbumRepository();
mockLocalAssetRepo = MockLocalAssetRepository();
mockHostService = MockHostService();
sut = DeviceSyncService(
albumMediaRepository: mockAlbumMediaRepo,
localAlbumRepository: mockLocalAlbumRepo,
localAssetRepository: mockLocalAssetRepo,
hostService: mockHostService,
);
registerFallbackValue(LocalAlbumStub.album1);
@ -51,8 +51,6 @@ void main() {
.thenAnswer((_) async => true);
when(() => mockLocalAlbumRepo.getAssetsForAlbum(any()))
.thenAnswer((_) async => []);
when(() => mockLocalAssetRepo.get(any()))
.thenAnswer((_) async => LocalAssetStub.image1);
when(() => mockAlbumMediaRepo.refresh(any())).thenAnswer(
(inv) async =>
LocalAlbumStub.album1.copyWith(id: inv.positionalArguments.first),
@ -250,7 +248,7 @@ void main() {
group('addAlbum', () {
test(
'refreshes, gets assets, sets thumbnail, and inserts for non-empty album',
'refreshes, gets assets, and inserts for non-empty album',
() async {
final newAlbum = LocalAlbumStub.album1.copyWith(assetCount: 0);
final refreshedAlbum =
@ -284,38 +282,33 @@ void main() {
expect(capturedAlbum.id, newAlbum.id);
expect(capturedAlbum.assetCount, refreshedAlbum.assetCount);
expect(capturedAlbum.updatedAt, refreshedAlbum.updatedAt);
expect(capturedAlbum.thumbnailId, assets.first.id);
expect(listEquals(capturedAssets, assets), isTrue);
},
);
test(
'refreshes, skips assets, sets null thumbnail, and inserts for empty album',
() async {
final newAlbum = LocalAlbumStub.album1.copyWith(assetCount: 0);
final refreshedAlbum =
newAlbum.copyWith(updatedAt: DateTime(2024), assetCount: 0);
test('refreshes, skips assets, and inserts for empty album', () async {
final newAlbum = LocalAlbumStub.album1.copyWith(assetCount: 0);
final refreshedAlbum =
newAlbum.copyWith(updatedAt: DateTime(2024), assetCount: 0);
when(() => mockAlbumMediaRepo.refresh(newAlbum.id))
.thenAnswer((_) async => refreshedAlbum);
when(() => mockAlbumMediaRepo.refresh(newAlbum.id))
.thenAnswer((_) async => refreshedAlbum);
await sut.addAlbum(newAlbum);
await sut.addAlbum(newAlbum);
verify(() => mockAlbumMediaRepo.refresh(newAlbum.id)).called(1);
verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id));
verify(() => mockAlbumMediaRepo.refresh(newAlbum.id)).called(1);
verifyNever(() => mockAlbumMediaRepo.getAssetsForAlbum(newAlbum.id));
final captured =
verify(() => mockLocalAlbumRepo.insert(captureAny(), captureAny()))
.captured;
final capturedAlbum = captured.first as LocalAlbum;
final capturedAssets = captured[1] as List<Object?>;
final captured =
verify(() => mockLocalAlbumRepo.insert(captureAny(), captureAny()))
.captured;
final capturedAlbum = captured.first as LocalAlbum;
final capturedAssets = captured[1] as List<Object?>;
expect(capturedAlbum.id, newAlbum.id);
expect(capturedAlbum.assetCount, 0);
expect(capturedAlbum.thumbnailId, isNull);
expect(capturedAssets, isEmpty);
},
);
expect(capturedAlbum.id, newAlbum.id);
expect(capturedAlbum.assetCount, 0);
expect(capturedAssets, isEmpty);
});
});
group('removeAlbum', () {
@ -361,11 +354,7 @@ void main() {
updateTimeCond: any(named: 'updateTimeCond'),
),
).thenAnswer((_) async => [newAsset]);
final dbAlbumNoThumb =
dbAlbum.copyWith(thumbnailId: const NullableValue.absent());
final result =
await sut.updateAlbum(dbAlbumNoThumb, LocalAlbumStub.album1);
final result = await sut.updateAlbum(dbAlbum, LocalAlbumStub.album1);
expect(result, isTrue);
verify(() => mockAlbumMediaRepo.refresh(dbAlbum.id)).called(1);
@ -376,7 +365,6 @@ void main() {
updateTimeCond: any(named: 'updateTimeCond'),
),
).called(1);
verifyNever(() => mockLocalAssetRepo.get(any()));
verify(() => mockLocalAlbumRepo.transaction<void>(any())).called(1);
verify(() => mockLocalAlbumRepo.addAssets(dbAlbum.id, [newAsset]))
@ -385,10 +373,7 @@ void main() {
() => mockLocalAlbumRepo.update(
any(
that: predicate<LocalAlbum>(
(a) =>
a.id == dbAlbum.id &&
a.assetCount == 2 &&
a.thumbnailId == newAsset.id,
(a) => a.id == dbAlbum.id && a.assetCount == 2,
),
),
),
@ -437,10 +422,7 @@ void main() {
() => mockLocalAlbumRepo.update(
any(
that: predicate<LocalAlbum>(
(a) =>
a.id == dbAlbum.id &&
a.assetCount == 0 &&
a.thumbnailId == null,
(a) => a.id == dbAlbum.id && a.assetCount == 0,
),
),
),
@ -508,11 +490,8 @@ void main() {
});
group('checkAddition', () {
final dbAlbum = LocalAlbumStub.album1.copyWith(
updatedAt: DateTime(2024, 1, 1, 10, 0, 0),
assetCount: 1,
thumbnailId: const NullableValue.value("thumb1"),
);
final dbAlbum = LocalAlbumStub.album1
.copyWith(updatedAt: DateTime(2024, 1, 1, 10, 0, 0), assetCount: 1);
final refreshedAlbum = dbAlbum.copyWith(
updatedAt: DateTime(2024, 1, 1, 11, 0, 0),
assetCount: 2,
@ -530,13 +509,6 @@ void main() {
),
).thenAnswer((_) async => [newAsset]);
when(() => mockLocalAssetRepo.get("thumb1")).thenAnswer(
(_) async => LocalAssetStub.image1.copyWith(
id: "thumb1",
createdAt: DateTime(2024, 1, 1, 9, 0, 0),
),
);
final result = await sut.checkAddition(dbAlbum, refreshedAlbum);
expect(result, isTrue);
@ -546,19 +518,15 @@ void main() {
updateTimeCond: any(named: 'updateTimeCond'),
),
).called(1);
verify(() => mockLocalAssetRepo.get("thumb1")).called(1);
verify(() => mockLocalAlbumRepo.addAssets(dbAlbum.id, [newAsset]))
.called(1);
verify(
() => mockLocalAlbumRepo.update(
any(
that: predicate<LocalAlbum>(
(a) =>
a.id == dbAlbum.id &&
a.assetCount == 2 &&
a.updatedAt == refreshedAlbum.updatedAt &&
a.thumbnailId == newAsset.id,
),
that: predicate<LocalAlbum>((a) =>
a.id == dbAlbum.id &&
a.assetCount == 2 &&
a.updatedAt == refreshedAlbum.updatedAt),
),
),
).called(1);
@ -566,93 +534,6 @@ void main() {
.called(1);
});
test('returns true and keeps old thumbnail if newer', () async {
final newAsset = LocalAssetStub.image2.copyWith(
id: "asset2",
createdAt: DateTime(2024, 1, 1, 8, 0, 0),
);
when(
() => mockAlbumMediaRepo.getAssetsForAlbum(
dbAlbum.id,
updateTimeCond: any(named: 'updateTimeCond'),
),
).thenAnswer((_) async => [newAsset]);
when(() => mockLocalAssetRepo.get("thumb1")).thenAnswer(
(_) async => LocalAssetStub.image1.copyWith(
id: "thumb1",
createdAt: DateTime(2024, 1, 1, 9, 0, 0),
),
);
final result = await sut.checkAddition(dbAlbum, refreshedAlbum);
expect(result, isTrue);
verify(
() => mockAlbumMediaRepo.getAssetsForAlbum(
dbAlbum.id,
updateTimeCond: any(named: 'updateTimeCond'),
),
).called(1);
verify(() => mockLocalAssetRepo.get("thumb1")).called(1);
verify(() => mockLocalAlbumRepo.addAssets(dbAlbum.id, [newAsset]))
.called(1);
verify(
() => mockLocalAlbumRepo.update(
any(
that: predicate<LocalAlbum>(
(a) =>
a.id == dbAlbum.id &&
a.assetCount == 2 &&
a.updatedAt == refreshedAlbum.updatedAt &&
a.thumbnailId == "thumb1",
),
),
),
).called(1);
});
test('returns true and sets new thumbnail if db thumb is null', () async {
final dbAlbumNoThumb =
dbAlbum.copyWith(thumbnailId: const NullableValue.empty());
final newAsset = LocalAssetStub.image2.copyWith(
id: "asset2",
createdAt: DateTime(2024, 1, 1, 10, 30, 0),
);
when(
() => mockAlbumMediaRepo.getAssetsForAlbum(
dbAlbum.id,
updateTimeCond: any(named: 'updateTimeCond'),
),
).thenAnswer((_) async => [newAsset]);
final result = await sut.checkAddition(dbAlbumNoThumb, refreshedAlbum);
expect(result, isTrue);
verify(
() => mockAlbumMediaRepo.getAssetsForAlbum(
dbAlbum.id,
updateTimeCond: any(named: 'updateTimeCond'),
),
).called(1);
verifyNever(() => mockLocalAssetRepo.get(any()));
verify(() => mockLocalAlbumRepo.addAssets(dbAlbum.id, [newAsset]))
.called(1);
verify(
() => mockLocalAlbumRepo.update(
any(
that: predicate<LocalAlbum>(
(a) =>
a.id == dbAlbum.id &&
a.assetCount == 2 &&
a.updatedAt == refreshedAlbum.updatedAt &&
a.thumbnailId == newAsset.id,
),
),
),
).called(1);
});
test('returns false if assetCount decreased', () async {
final decreasedCountAlbum = refreshedAlbum.copyWith(assetCount: 0);
final result = await sut.checkAddition(dbAlbum, decreasedCountAlbum);
@ -725,7 +606,6 @@ void main() {
final dbAlbum = LocalAlbumStub.album1.copyWith(
updatedAt: DateTime(2024, 1, 1),
assetCount: 2,
thumbnailId: const NullableValue.value("asset1"),
);
final refreshedAlbum = dbAlbum.copyWith(
updatedAt: DateTime(2024, 1, 2),
@ -760,7 +640,7 @@ void main() {
when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id))
.thenAnswer((_) async => [dbAsset1, dbAsset2]);
final result = await sut.fullSync(dbAlbum, emptyRefreshedAlbum);
final result = await sut.fullDiff(dbAlbum, emptyRefreshedAlbum);
expect(result, isTrue);
verifyNever(
@ -773,13 +653,10 @@ void main() {
verify(
() => mockLocalAlbumRepo.update(
any(
that: predicate<LocalAlbum>(
(a) =>
a.id == dbAlbum.id &&
a.assetCount == 0 &&
a.updatedAt == emptyRefreshedAlbum.updatedAt &&
a.thumbnailId == null,
),
that: predicate<LocalAlbum>((a) =>
a.id == dbAlbum.id &&
a.assetCount == 0 &&
a.updatedAt == emptyRefreshedAlbum.updatedAt),
),
),
).called(1);
@ -789,10 +666,7 @@ void main() {
});
test('handles empty DB album -> adds all device assets', () async {
final emptyDbAlbum = dbAlbum.copyWith(
assetCount: 0,
thumbnailId: const NullableValue.empty(),
);
final emptyDbAlbum = dbAlbum.copyWith(assetCount: 0);
final deviceAssets = [deviceAsset1, deviceAsset3];
deviceAssets.sort((a, b) => a.createdAt.compareTo(b.createdAt));
@ -804,7 +678,7 @@ void main() {
when(() => mockLocalAlbumRepo.getAssetsForAlbum(emptyDbAlbum.id))
.thenAnswer((_) async => []);
final result = await sut.fullSync(emptyDbAlbum, refreshedWithAssets);
final result = await sut.fullDiff(emptyDbAlbum, refreshedWithAssets);
expect(result, isTrue);
verify(() => mockAlbumMediaRepo.getAssetsForAlbum(emptyDbAlbum.id))
@ -816,13 +690,10 @@ void main() {
verify(
() => mockLocalAlbumRepo.update(
any(
that: predicate<LocalAlbum>(
(a) =>
a.id == emptyDbAlbum.id &&
a.assetCount == deviceAssets.length &&
a.updatedAt == refreshedWithAssets.updatedAt &&
a.thumbnailId == deviceAssets.first.id,
),
that: predicate<LocalAlbum>((a) =>
a.id == emptyDbAlbum.id &&
a.assetCount == deviceAssets.length &&
a.updatedAt == refreshedWithAssets.updatedAt),
),
),
).called(1);
@ -844,7 +715,7 @@ void main() {
(_) async => dbAssets,
);
final result = await sut.fullSync(dbAlbum, currentRefreshedAlbum);
final result = await sut.fullDiff(dbAlbum, currentRefreshedAlbum);
expect(result, isTrue);
verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).called(1);
@ -871,13 +742,10 @@ void main() {
verify(
() => mockLocalAlbumRepo.update(
any(
that: predicate<LocalAlbum>(
(a) =>
a.id == dbAlbum.id &&
a.assetCount == 2 &&
a.updatedAt == currentRefreshedAlbum.updatedAt &&
a.thumbnailId == deviceAssets.first.id,
),
that: predicate<LocalAlbum>((a) =>
a.id == dbAlbum.id &&
a.assetCount == 2 &&
a.updatedAt == currentRefreshedAlbum.updatedAt),
),
),
).called(1);
@ -901,7 +769,7 @@ void main() {
when(() => mockLocalAlbumRepo.getAssetsForAlbum(dbAlbum.id))
.thenAnswer((_) async => dbAssets);
final result = await sut.fullSync(dbAlbum, currentRefreshedAlbum);
final result = await sut.fullDiff(dbAlbum, currentRefreshedAlbum);
expect(result, isTrue);
verify(() => mockAlbumMediaRepo.getAssetsForAlbum(dbAlbum.id)).called(1);
@ -912,13 +780,10 @@ void main() {
verify(
() => mockLocalAlbumRepo.update(
any(
that: predicate<LocalAlbum>(
(a) =>
a.id == dbAlbum.id &&
a.assetCount == 2 &&
a.updatedAt == currentRefreshedAlbum.updatedAt &&
a.thumbnailId == deviceAssets.first.id,
),
that: predicate<LocalAlbum>((a) =>
a.id == dbAlbum.id &&
a.assetCount == 2 &&
a.updatedAt == currentRefreshedAlbum.updatedAt),
),
),
).called(1);