From dbdb64f6c55c9b75b2a3168995563c7c2c413f41 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 29 May 2025 21:12:00 +0530 Subject: [PATCH] feat: delta sync (#18428) * feat: delta sync * fix: ignore iCloud assets * feat: dev logs * add full sync button * remove photo_manager dep for sync * misc logs and fix * add time taken to DLog * fix: build release iOS * ios sync go brrr * rename local sync service * update isar fork * rename to platform assets / albums * fix ci check --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- .gitattributes | 3 + .github/workflows/build-mobile.yml | 4 + .github/workflows/static_analysis.yml | 6 +- mobile/analysis_options.yaml | 1 + mobile/android/app/build.gradle | 137 ++-- .../app/alextran/immich/MainActivity.kt | 13 + .../app/alextran/immich/sync/Messages.g.kt | 393 +++++++++++ .../alextran/immich/sync/MessagesImpl26.kt | 24 + .../alextran/immich/sync/MessagesImpl30.kt | 89 +++ .../alextran/immich/sync/MessagesImplBase.kt | 177 +++++ mobile/android/settings.gradle | 35 +- mobile/immich_lint/pubspec.lock | 55 +- mobile/immich_lint/pubspec.yaml | 6 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 16 +- mobile/ios/Runner/AppDelegate.swift | 3 + mobile/ios/Runner/Sync/Messages.g.swift | 446 ++++++++++++ mobile/ios/Runner/Sync/MessagesImpl.swift | 246 +++++++ mobile/lib/constants/constants.dart | 1 + .../interfaces/local_album.interface.dart | 34 + .../lib/domain/models/asset/asset.model.dart | 47 ++ .../domain/models/asset/base_asset.model.dart | 76 ++ .../models/asset/local_asset.model.dart | 74 ++ .../lib/domain/models/local_album.model.dart | 70 ++ .../domain/services/local_sync.service.dart | 379 ++++++++++ mobile/lib/domain/utils/background_sync.dart | 33 +- .../entities/local_album.entity.dart | 18 + .../entities/local_album.entity.drift.dart | 497 +++++++++++++ .../entities/local_album_asset.entity.dart | 17 + .../local_album_asset.entity.drift.dart | 565 +++++++++++++++ .../entities/local_asset.entity.dart | 17 + .../entities/local_asset.entity.drift.dart | 658 ++++++++++++++++++ .../repositories/db.repository.dart | 17 +- .../repositories/db.repository.drift.dart | 45 +- .../repositories/local_album.repository.dart | 366 ++++++++++ .../lib/infrastructure/utils/asset.mixin.dart | 10 + mobile/lib/platform/native_sync_api.g.dart | 501 +++++++++++++ .../presentation/pages/dev/dev_logger.dart | 68 ++ .../pages/dev/feat_in_development.page.dart | 174 +++++ .../pages/dev/local_media_stat.page.dart | 125 ++++ .../infrastructure/album.provider.dart | 8 + .../infrastructure/platform.provider.dart | 4 + ...tream.provider.dart => sync.provider.dart} | 12 + mobile/lib/routing/router.dart | 10 + mobile/lib/routing/router.gr.dart | 415 ++++------- mobile/lib/widgets/common/immich_app_bar.dart | 7 +- mobile/makefile | 11 +- mobile/pigeon/native_sync_api.dart | 89 +++ mobile/pubspec.lock | 98 +-- mobile/pubspec.yaml | 22 +- 49 files changed, 5634 insertions(+), 488 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt create mode 100644 mobile/ios/Runner/Sync/Messages.g.swift create mode 100644 mobile/ios/Runner/Sync/MessagesImpl.swift create mode 100644 mobile/lib/domain/interfaces/local_album.interface.dart create mode 100644 mobile/lib/domain/models/asset/asset.model.dart create mode 100644 mobile/lib/domain/models/asset/base_asset.model.dart create mode 100644 mobile/lib/domain/models/asset/local_asset.model.dart create mode 100644 mobile/lib/domain/models/local_album.model.dart create mode 100644 mobile/lib/domain/services/local_sync.service.dart create mode 100644 mobile/lib/infrastructure/entities/local_album.entity.dart create mode 100644 mobile/lib/infrastructure/entities/local_album.entity.drift.dart create mode 100644 mobile/lib/infrastructure/entities/local_album_asset.entity.dart create mode 100644 mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart create mode 100644 mobile/lib/infrastructure/entities/local_asset.entity.dart create mode 100644 mobile/lib/infrastructure/entities/local_asset.entity.drift.dart create mode 100644 mobile/lib/infrastructure/repositories/local_album.repository.dart create mode 100644 mobile/lib/infrastructure/utils/asset.mixin.dart create mode 100644 mobile/lib/platform/native_sync_api.g.dart create mode 100644 mobile/lib/presentation/pages/dev/dev_logger.dart create mode 100644 mobile/lib/presentation/pages/dev/feat_in_development.page.dart create mode 100644 mobile/lib/presentation/pages/dev/local_media_stat.page.dart create mode 100644 mobile/lib/providers/infrastructure/album.provider.dart create mode 100644 mobile/lib/providers/infrastructure/platform.provider.dart rename mobile/lib/providers/infrastructure/{sync_stream.provider.dart => sync.provider.dart} (64%) create mode 100644 mobile/pigeon/native_sync_api.dart diff --git a/.gitattributes b/.gitattributes index 2e8a45ca5c3..3d43ff20ed9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,9 @@ mobile/lib/**/*.g.dart linguist-generated=true mobile/lib/**/*.drift.dart -diff -merge mobile/lib/**/*.drift.dart linguist-generated=true +mobile/drift_schemas/main/drift_schema_*.json -diff -merge +mobile/drift_schemas/main/drift_schema_*.json linguist-generated=true + open-api/typescript-sdk/fetch-client.ts -diff -merge open-api/typescript-sdk/fetch-client.ts linguist-generated=true diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 0fcc4f1d7c9..33912d687cb 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -93,6 +93,10 @@ jobs: run: make translation working-directory: ./mobile + - name: Generate platform APIs + run: make pigeon + working-directory: ./mobile + - name: Build Android App Bundle working-directory: ./mobile env: diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 7cd28228dc2..754c0c38b31 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -59,13 +59,17 @@ jobs: working-directory: ./mobile - name: Generate translation file - run: make translation; dart format lib/generated/codegen_loader.g.dart + run: make translation working-directory: ./mobile - name: Run Build Runner run: make build working-directory: ./mobile + - name: Generate platform API + run: make pigeon + working-directory: ./mobile + - name: Find file changes uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 id: verify-changed-files diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 854f852e3cc..dc81c10dece 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -55,6 +55,7 @@ custom_lint: restrict: package:photo_manager allowed: # required / wanted + - 'lib/infrastructure/repositories/album_media.repository.dart' - 'lib/repositories/{album,asset,file}_media.repository.dart' # acceptable exceptions for the time being - lib/entities/asset.entity.dart # to provide local AssetEntity for now diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 0ec511d9f12..7455ae99a25 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -1,103 +1,106 @@ plugins { - id "com.android.application" - id "kotlin-android" - id "dev.flutter.flutter-gradle-plugin" - id 'com.google.devtools.ksp' + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" + id 'com.google.devtools.ksp' } def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { - localPropertiesFile.withInputStream { localProperties.load(it) } + localPropertiesFile.withInputStream { localProperties.load(it) } } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { - flutterVersionCode = '1' + flutterVersionCode = '1' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { - flutterVersionName = '1.0' + flutterVersionName = '1.0' } def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { - keystorePropertiesFile.withInputStream { keystoreProperties.load(it) } + keystorePropertiesFile.withInputStream { keystoreProperties.load(it) } } android { - compileSdkVersion 35 + compileSdkVersion 35 - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - coreLibraryDesugaringEnabled true + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + coreLibraryDesugaringEnabled true + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "app.alextran.immich" + minSdkVersion 26 + targetSdkVersion 35 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + signingConfigs { + release { + def keyAliasVal = System.getenv("ALIAS") + def keyPasswordVal = System.getenv("ANDROID_KEY_PASSWORD") + def storePasswordVal = System.getenv("ANDROID_STORE_PASSWORD") + + keyAlias keyAliasVal ? keyAliasVal : keystoreProperties['keyAlias'] + keyPassword keyPasswordVal ? keyPasswordVal : keystoreProperties['keyPassword'] + storeFile file("../key.jks") ? file("../key.jks") : file(keystoreProperties['storeFile']) + storePassword storePasswordVal ? storePasswordVal : keystoreProperties['storePassword'] + } + } + + buildTypes { + debug { + applicationIdSuffix '.debug' + versionNameSuffix '-DEBUG' } - kotlinOptions { - jvmTarget = '17' + release { + signingConfig signingConfigs.release } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - applicationId "app.alextran.immich" - minSdkVersion 26 - targetSdkVersion 35 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - signingConfigs { - release { - def keyAliasVal = System.getenv("ALIAS") - def keyPasswordVal = System.getenv("ANDROID_KEY_PASSWORD") - def storePasswordVal = System.getenv("ANDROID_STORE_PASSWORD") - - keyAlias keyAliasVal ? keyAliasVal : keystoreProperties['keyAlias'] - keyPassword keyPasswordVal ? keyPasswordVal : keystoreProperties['keyPassword'] - storeFile file("../key.jks") ? file("../key.jks") : file(keystoreProperties['storeFile']) - storePassword storePasswordVal ? storePasswordVal : keystoreProperties['storePassword'] - } - } - - buildTypes { - debug { - applicationIdSuffix '.debug' - versionNameSuffix '-DEBUG' - } - - release { - signingConfig signingConfigs.release - } - } - namespace 'app.alextran.immich' + } + namespace 'app.alextran.immich' } flutter { - source '../..' + source '../..' } dependencies { - def kotlin_version = '2.0.20' - def kotlin_coroutines_version = '1.9.0' - def work_version = '2.9.1' - def concurrent_version = '1.2.0' - def guava_version = '33.3.1-android' - def glide_version = '4.16.0' + def kotlin_version = '2.0.20' + def kotlin_coroutines_version = '1.9.0' + def work_version = '2.9.1' + def concurrent_version = '1.2.0' + def guava_version = '33.3.1-android' + def glide_version = '4.16.0' + def serialization_version = '1.8.1' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" - implementation "androidx.work:work-runtime-ktx:$work_version" - implementation "androidx.concurrent:concurrent-futures:$concurrent_version" - implementation "com.google.guava:guava:$guava_version" - implementation "com.github.bumptech.glide:glide:$glide_version" - ksp "com.github.bumptech.glide:ksp:$glide_version" - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" + implementation "androidx.work:work-runtime-ktx:$work_version" + implementation "androidx.concurrent:concurrent-futures:$concurrent_version" + implementation "com.google.guava:guava:$guava_version" + implementation "com.github.bumptech.glide:glide:$glide_version" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization_version" + + ksp "com.github.bumptech.glide:ksp:$glide_version" + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' } // This is uncommented in F-Droid build script diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index c1e5152d28b..f9c4ee2a1ff 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -1,6 +1,11 @@ package app.alextran.immich +import android.os.Build +import android.os.ext.SdkExtensions import androidx.annotation.NonNull +import app.alextran.immich.sync.NativeSyncApi +import app.alextran.immich.sync.NativeSyncApiImpl26 +import app.alextran.immich.sync.NativeSyncApiImpl30 import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine @@ -10,5 +15,13 @@ class MainActivity : FlutterFragmentActivity() { flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(HttpSSLOptionsPlugin()) // No need to set up method channel here as it's now handled in the plugin + + val nativeSyncApiImpl = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) { + NativeSyncApiImpl26(this) + } else { + NativeSyncApiImpl30(this) + } + NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt new file mode 100644 index 00000000000..f4dbda730b0 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt @@ -0,0 +1,393 @@ +// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.sync + +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 { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + 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).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 PlatformAsset ( + val id: String, + val name: String, + val type: Long, + val createdAt: Long? = null, + val updatedAt: Long? = null, + val durationInSeconds: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): PlatformAsset { + 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 Long? + val updatedAt = pigeonVar_list[4] as Long? + val durationInSeconds = pigeonVar_list[5] as Long + return PlatformAsset(id, name, type, createdAt, updatedAt, durationInSeconds) + } + } + fun toList(): List { + return listOf( + id, + name, + type, + createdAt, + updatedAt, + durationInSeconds, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PlatformAsset) { + 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 PlatformAlbum ( + val id: String, + val name: String, + val updatedAt: Long? = null, + val isCloud: Boolean, + val assetCount: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): PlatformAlbum { + val id = pigeonVar_list[0] as String + val name = pigeonVar_list[1] as String + val updatedAt = pigeonVar_list[2] as Long? + val isCloud = pigeonVar_list[3] as Boolean + val assetCount = pigeonVar_list[4] as Long + return PlatformAlbum(id, name, updatedAt, isCloud, assetCount) + } + } + fun toList(): List { + return listOf( + id, + name, + updatedAt, + isCloud, + assetCount, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PlatformAlbum) { + 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 hasChanges: Boolean, + val updates: List, + val deletes: List, + val assetAlbums: Map> +) + { + companion object { + fun fromList(pigeonVar_list: List): SyncDelta { + val hasChanges = pigeonVar_list[0] as Boolean + val updates = pigeonVar_list[1] as List + val deletes = pigeonVar_list[2] as List + val assetAlbums = pigeonVar_list[3] as Map> + return SyncDelta(hasChanges, updates, deletes, assetAlbums) + } + } + fun toList(): List { + return listOf( + hasChanges, + updates, + deletes, + assetAlbums, + ) + } + 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)?.let { + PlatformAsset.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + PlatformAlbum.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + SyncDelta.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is PlatformAsset -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is PlatformAlbum -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is SyncDelta -> { + stream.write(131) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface NativeSyncApi { + fun shouldFullSync(): Boolean + fun getMediaChanges(): SyncDelta + fun checkpointSync() + fun clearSyncCheckpoint() + fun getAssetIdsForAlbum(albumId: String): List + fun getAlbums(): List + fun getAssetsCountSince(albumId: String, timestamp: Long): Long + fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List + + companion object { + /** The codec used by NativeSyncApi. */ + val codec: MessageCodec by lazy { + MessagesPigeonCodec() + } + /** Sets up an instance of `NativeSyncApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: NativeSyncApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val taskQueue = binaryMessenger.makeBackgroundTaskQueue() + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.shouldFullSync()) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getMediaChanges()) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.checkpointSync() + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.clearSyncCheckpoint() + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val albumIdArg = args[0] as String + val wrapped: List = try { + listOf(api.getAssetIdsForAlbum(albumIdArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getAlbums()) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val albumIdArg = args[0] as String + val timestampArg = args[1] as Long + val wrapped: List = try { + listOf(api.getAssetsCountSince(albumIdArg, timestampArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val albumIdArg = args[0] as String + val updatedTimeCondArg = args[1] as Long? + val wrapped: List = try { + listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt new file mode 100644 index 00000000000..5deacc30db1 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt @@ -0,0 +1,24 @@ +package app.alextran.immich.sync + +import android.content.Context + + +class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi { + override fun shouldFullSync(): Boolean { + return true + } + + // No-op for Android 10 and below + override fun checkpointSync() { + // Cannot throw exception as this is called from the Dart side + // during the full sync process as well + } + + override fun clearSyncCheckpoint() { + // No-op for Android 10 and below + } + + override fun getMediaChanges(): SyncDelta { + throw IllegalStateException("Method not supported on this Android version.") + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt new file mode 100644 index 00000000000..052032e143a --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt @@ -0,0 +1,89 @@ +package app.alextran.immich.sync + +import android.content.Context +import android.os.Build +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresExtension +import kotlinx.serialization.json.Json + +@RequiresApi(Build.VERSION_CODES.Q) +@RequiresExtension(extension = Build.VERSION_CODES.R, version = 1) +class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi { + private val ctx: Context = context.applicationContext + private val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + + companion object { + const val SHARED_PREF_NAME = "Immich::MediaManager" + const val SHARED_PREF_MEDIA_STORE_VERSION_KEY = "MediaStore::getVersion" + const val SHARED_PREF_MEDIA_STORE_GEN_KEY = "MediaStore::getGeneration" + } + + private fun getSavedGenerationMap(): Map { + return prefs.getString(SHARED_PREF_MEDIA_STORE_GEN_KEY, null)?.let { + Json.decodeFromString>(it) + } ?: emptyMap() + } + + override fun clearSyncCheckpoint() { + prefs.edit().apply { + remove(SHARED_PREF_MEDIA_STORE_VERSION_KEY) + remove(SHARED_PREF_MEDIA_STORE_GEN_KEY) + apply() + } + } + + override fun shouldFullSync(): Boolean = + MediaStore.getVersion(ctx) != prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null) + + override fun checkpointSync() { + val genMap = MediaStore.getExternalVolumeNames(ctx) + .associateWith { MediaStore.getGeneration(ctx, it) } + + prefs.edit().apply { + putString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, MediaStore.getVersion(ctx)) + putString(SHARED_PREF_MEDIA_STORE_GEN_KEY, Json.encodeToString(genMap)) + apply() + } + } + + override fun getMediaChanges(): SyncDelta { + val genMap = getSavedGenerationMap() + val currentVolumes = MediaStore.getExternalVolumeNames(ctx) + val changed = mutableListOf() + val deleted = mutableListOf() + val assetAlbums = mutableMapOf>() + var hasChanges = genMap.keys != currentVolumes + + for (volume in currentVolumes) { + val currentGen = MediaStore.getGeneration(ctx, volume) + val storedGen = genMap[volume] ?: 0 + if (currentGen <= storedGen) { + continue + } + + hasChanges = true + + val selection = + "$MEDIA_SELECTION AND (${MediaStore.MediaColumns.GENERATION_MODIFIED} > ? OR ${MediaStore.MediaColumns.GENERATION_ADDED} > ?)" + val selectionArgs = arrayOf( + *MEDIA_SELECTION_ARGS, + storedGen.toString(), + storedGen.toString() + ) + + getAssets(getCursor(volume, selection, selectionArgs)).forEach { + when (it) { + is AssetResult.ValidAsset -> { + changed.add(it.asset) + assetAlbums[it.asset.id] = listOf(it.albumId) + } + + is AssetResult.InvalidAsset -> deleted.add(it.assetId) + } + } + } + // Unmounted volumes are handled in dart when the album is removed + return SyncDelta(hasChanges, changed, deleted, assetAlbums) + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt new file mode 100644 index 00000000000..23228553077 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -0,0 +1,177 @@ +package app.alextran.immich.sync + +import android.annotation.SuppressLint +import android.content.Context +import android.database.Cursor +import android.provider.MediaStore +import java.io.File + +sealed class AssetResult { + data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult() + data class InvalidAsset(val assetId: String) : AssetResult() +} + +@SuppressLint("InlinedApi") +open class NativeSyncApiImplBase(context: Context) { + private val ctx: Context = context.applicationContext + + companion object { + const val MEDIA_SELECTION = + "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)" + val MEDIA_SELECTION_ARGS = arrayOf( + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString() + ) + const val BUCKET_SELECTION = "(${MediaStore.Files.FileColumns.BUCKET_ID} = ?)" + val ASSET_PROJECTION = arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DATA, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.DATE_TAKEN, + MediaStore.MediaColumns.DATE_ADDED, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.Files.FileColumns.MEDIA_TYPE, + MediaStore.MediaColumns.BUCKET_ID, + MediaStore.MediaColumns.DURATION + ) + } + + protected fun getCursor( + volume: String, + selection: String, + selectionArgs: Array, + projection: Array = ASSET_PROJECTION, + sortOrder: String? = null + ): Cursor? = ctx.contentResolver.query( + MediaStore.Files.getContentUri(volume), + projection, + selection, + selectionArgs, + sortOrder, + ) + + protected fun getAssets(cursor: Cursor?): Sequence { + return sequence { + cursor?.use { c -> + val idColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val dataColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) + val nameColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) + val dateTakenColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN) + val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) + val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) + val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) + val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) + val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION) + + while (c.moveToNext()) { + val id = c.getLong(idColumn).toString() + + val path = c.getString(dataColumn) + if (path.isNullOrBlank() || !File(path).exists()) { + yield(AssetResult.InvalidAsset(id)) + continue + } + + val mediaType = c.getInt(mediaTypeColumn) + val name = c.getString(nameColumn) + // Date taken is milliseconds since epoch, Date added is seconds since epoch + val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) + ?: c.getLong(dateAddedColumn) + // Date modified is seconds since epoch + val modifiedAt = c.getLong(dateModifiedColumn) + // Duration is milliseconds + val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0 + else c.getLong(durationColumn) / 1000 + val bucketId = c.getString(bucketIdColumn) + + val asset = PlatformAsset(id, name, mediaType.toLong(), createdAt, modifiedAt, duration) + yield(AssetResult.ValidAsset(asset, bucketId)) + } + } + } + } + + fun getAlbums(): List { + val albums = mutableListOf() + val albumsCount = mutableMapOf() + + val projection = arrayOf( + MediaStore.Files.FileColumns.BUCKET_ID, + MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME, + MediaStore.Files.FileColumns.DATE_MODIFIED, + ) + val selection = + "(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION" + + getCursor( + MediaStore.VOLUME_EXTERNAL, + selection, + MEDIA_SELECTION_ARGS, + projection, + "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" + )?.use { cursor -> + val bucketIdColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_ID) + val bucketNameColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME) + val dateModified = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED) + + while (cursor.moveToNext()) { + val id = cursor.getString(bucketIdColumn) + + val count = albumsCount.getOrDefault(id, 0) + if (count != 0) { + albumsCount[id] = count + 1 + continue + } + + val name = cursor.getString(bucketNameColumn) + val updatedAt = cursor.getLong(dateModified) + albums.add(PlatformAlbum(id, name, updatedAt, false, 0)) + albumsCount[id] = 1 + } + } + + return albums.map { it.copy(assetCount = albumsCount[it.id]?.toLong() ?: 0) } + .sortedBy { it.id } + } + + fun getAssetIdsForAlbum(albumId: String): List { + val projection = arrayOf(MediaStore.MediaColumns._ID) + + return getCursor( + MediaStore.VOLUME_EXTERNAL, + "$BUCKET_SELECTION AND $MEDIA_SELECTION", + arrayOf(albumId, *MEDIA_SELECTION_ARGS), + projection + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + generateSequence { + if (cursor.moveToNext()) cursor.getLong(idColumn).toString() else null + }.toList() + } ?: emptyList() + } + + fun getAssetsCountSince(albumId: String, timestamp: Long): Long = + getCursor( + MediaStore.VOLUME_EXTERNAL, + "$BUCKET_SELECTION AND ${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND $MEDIA_SELECTION", + arrayOf(albumId, timestamp.toString(), *MEDIA_SELECTION_ARGS), + )?.use { cursor -> cursor.count.toLong() } ?: 0L + + + fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List { + var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION" + val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS) + + if (updatedTimeCond != null) { + selection += " AND (${MediaStore.Files.FileColumns.DATE_MODIFIED} > ? OR ${MediaStore.Files.FileColumns.DATE_ADDED} > ?)" + selectionArgs.addAll(listOf(updatedTimeCond.toString(), updatedTimeCond.toString())) + } + + return getAssets(getCursor(MediaStore.VOLUME_EXTERNAL, selection, selectionArgs.toTypedArray())) + .mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset } + .toList() + } +} diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle index 74f8904a109..29c3a7c0567 100644 --- a/mobile/android/settings.gradle +++ b/mobile/android/settings.gradle @@ -1,26 +1,27 @@ pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - repositories { - google() - mavenCentral() - gradlePluginPortal() - } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } } plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.7.2' apply false - id "org.jetbrains.kotlin.android" version "2.0.20" apply false - id 'com.google.devtools.ksp' version '2.0.20-1.0.24' apply false + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version '8.7.2' apply false + id "org.jetbrains.kotlin.android" version "2.0.20" apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' apply false + id 'com.google.devtools.ksp' version '2.0.20-1.0.24' apply false } include ":app" diff --git a/mobile/immich_lint/pubspec.lock b/mobile/immich_lint/pubspec.lock index 6d4630f1fb2..263a43c22c0 100644 --- a/mobile/immich_lint/pubspec.lock +++ b/mobile/immich_lint/pubspec.lock @@ -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: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" url: "https://pub.dev" source: hosted - version: "2.3.8" + version: "3.1.0" 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: @@ -367,4 +362,4 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.6.0 <4.0.0" + dart: ">=3.7.0 <4.0.0" diff --git a/mobile/immich_lint/pubspec.yaml b/mobile/immich_lint/pubspec.yaml index 4cfd8abe819..2890a4a5954 100644 --- a/mobile/immich_lint/pubspec.yaml +++ b/mobile/immich_lint/pubspec.yaml @@ -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: diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 311f19857b0..3cbbf83f017 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -89,6 +89,16 @@ FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = Sync; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -175,6 +185,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + B2CF7F8C2DDE4EBB00744BF6 /* Sync */, FA9973382CF6DF4B000EF859 /* Runner.entitlements */, 65DD438629917FAD0047FFA8 /* BackgroundSync */, FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */, @@ -224,6 +235,9 @@ dependencies = ( FAC6F8992D287C890078CB2F /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + B2CF7F8C2DDE4EBB00744BF6 /* Sync */, + ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */; @@ -270,7 +284,6 @@ }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -278,6 +291,7 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + preferredProjectObjectVersion = 77; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index fd62618205c..55d08adc6aa 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -22,6 +22,9 @@ import UIKit BackgroundServicePlugin.registerBackgroundProcessing() BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) + + let controller: FlutterViewController = window?.rootViewController as! FlutterViewController + NativeSyncApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: NativeSyncApiImpl()) BackgroundServicePlugin.setPluginRegistrantCallback { registry in if !registry.hasPlugin("org.cocoapods.path-provider-foundation") { diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift new file mode 100644 index 00000000000..0d7a3026884 --- /dev/null +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -0,0 +1,446 @@ +// Autogenerated from Pigeon (v25.3.2), 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 ?? ""), details: \(details ?? "")" + } +} + +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(_ 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 PlatformAsset: Hashable { + var id: String + var name: String + var type: Int64 + var createdAt: Int64? = nil + var updatedAt: Int64? = nil + var durationInSeconds: Int64 + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> PlatformAsset? { + let id = pigeonVar_list[0] as! String + let name = pigeonVar_list[1] as! String + let type = pigeonVar_list[2] as! Int64 + let createdAt: Int64? = nilOrValue(pigeonVar_list[3]) + let updatedAt: Int64? = nilOrValue(pigeonVar_list[4]) + let durationInSeconds = pigeonVar_list[5] as! Int64 + + return PlatformAsset( + id: id, + name: name, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds + ) + } + func toList() -> [Any?] { + return [ + id, + name, + type, + createdAt, + updatedAt, + durationInSeconds, + ] + } + static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> 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 PlatformAlbum: Hashable { + var id: String + var name: String + var updatedAt: Int64? = nil + var isCloud: Bool + var assetCount: Int64 + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> PlatformAlbum? { + let id = pigeonVar_list[0] as! String + let name = pigeonVar_list[1] as! String + let updatedAt: Int64? = nilOrValue(pigeonVar_list[2]) + let isCloud = pigeonVar_list[3] as! Bool + let assetCount = pigeonVar_list[4] as! Int64 + + return PlatformAlbum( + id: id, + name: name, + updatedAt: updatedAt, + isCloud: isCloud, + assetCount: assetCount + ) + } + func toList() -> [Any?] { + return [ + id, + name, + updatedAt, + isCloud, + assetCount, + ] + } + static func == (lhs: PlatformAlbum, rhs: PlatformAlbum) -> 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 hasChanges: Bool + var updates: [PlatformAsset] + var deletes: [String] + var assetAlbums: [String: [String]] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SyncDelta? { + let hasChanges = pigeonVar_list[0] as! Bool + let updates = pigeonVar_list[1] as! [PlatformAsset] + let deletes = pigeonVar_list[2] as! [String] + let assetAlbums = pigeonVar_list[3] as! [String: [String]] + + return SyncDelta( + hasChanges: hasChanges, + updates: updates, + deletes: deletes, + assetAlbums: assetAlbums + ) + } + func toList() -> [Any?] { + return [ + hasChanges, + updates, + deletes, + assetAlbums, + ] + } + 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 PlatformAsset.fromList(self.readValue() as! [Any?]) + case 130: + return PlatformAlbum.fromList(self.readValue() as! [Any?]) + case 131: + 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? PlatformAsset { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? PlatformAlbum { + super.writeByte(130) + super.writeValue(value.toList()) + } else if let value = value as? SyncDelta { + super.writeByte(131) + 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 NativeSyncApi { + func shouldFullSync() throws -> Bool + func getMediaChanges() throws -> SyncDelta + func checkpointSync() throws + func clearSyncCheckpoint() throws + func getAssetIdsForAlbum(albumId: String) throws -> [String] + func getAlbums() throws -> [PlatformAlbum] + func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 + func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class NativeSyncApiSetup { + static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared } + /// Sets up an instance of `NativeSyncApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NativeSyncApi?, 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.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + shouldFullSyncChannel.setMessageHandler { _, reply in + do { + let result = try api.shouldFullSync() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + shouldFullSyncChannel.setMessageHandler(nil) + } + let getMediaChangesChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getMediaChangesChannel.setMessageHandler { _, reply in + do { + let result = try api.getMediaChanges() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getMediaChangesChannel.setMessageHandler(nil) + } + let checkpointSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + checkpointSyncChannel.setMessageHandler { _, reply in + do { + try api.checkpointSync() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + checkpointSyncChannel.setMessageHandler(nil) + } + let clearSyncCheckpointChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearSyncCheckpointChannel.setMessageHandler { _, reply in + do { + try api.clearSyncCheckpoint() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + clearSyncCheckpointChannel.setMessageHandler(nil) + } + let getAssetIdsForAlbumChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getAssetIdsForAlbumChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let albumIdArg = args[0] as! String + do { + let result = try api.getAssetIdsForAlbum(albumId: albumIdArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getAssetIdsForAlbumChannel.setMessageHandler(nil) + } + let getAlbumsChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getAlbumsChannel.setMessageHandler { _, reply in + do { + let result = try api.getAlbums() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getAlbumsChannel.setMessageHandler(nil) + } + let getAssetsCountSinceChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getAssetsCountSinceChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let albumIdArg = args[0] as! String + let timestampArg = args[1] as! Int64 + do { + let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getAssetsCountSinceChannel.setMessageHandler(nil) + } + let getAssetsForAlbumChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getAssetsForAlbumChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let albumIdArg = args[0] as! String + let updatedTimeCondArg: Int64? = nilOrValue(args[1]) + do { + let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getAssetsForAlbumChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift new file mode 100644 index 00000000000..5d2f08691d2 --- /dev/null +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -0,0 +1,246 @@ +import Photos + +struct AssetWrapper: Hashable, Equatable { + let asset: PlatformAsset + + init(with asset: PlatformAsset) { + self.asset = asset + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.asset.id) + } + + static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool { + return lhs.asset.id == rhs.asset.id + } +} + +extension PHAsset { + func toPlatformAsset() -> PlatformAsset { + return PlatformAsset( + id: localIdentifier, + name: title(), + type: Int64(mediaType.rawValue), + createdAt: creationDate.map { Int64($0.timeIntervalSince1970) }, + updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) }, + durationInSeconds: Int64(duration) + ) + } +} + +class NativeSyncApiImpl: NativeSyncApi { + private let defaults: UserDefaults + private let changeTokenKey = "immich:changeToken" + private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] + + init(with defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + @available(iOS 16, *) + private func getChangeToken() -> PHPersistentChangeToken? { + guard let data = defaults.data(forKey: changeTokenKey) else { + return nil + } + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data) + } + + @available(iOS 16, *) + private func saveChangeToken(token: PHPersistentChangeToken) -> Void { + guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else { + return + } + defaults.set(data, forKey: changeTokenKey) + } + + func clearSyncCheckpoint() -> Void { + defaults.removeObject(forKey: changeTokenKey) + } + + func checkpointSync() { + guard #available(iOS 16, *) else { + return + } + saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) + } + + func shouldFullSync() -> Bool { + guard #available(iOS 16, *), + PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized, + let storedToken = getChangeToken() else { + // When we do not have access to photo library, older iOS version or No token available, fallback to full sync + return true + } + + guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else { + // Cannot fetch persistent changes + return true + } + + return false + } + + func getAlbums() throws -> [PlatformAlbum] { + var albums: [PlatformAlbum] = [] + + albumTypes.forEach { type in + let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) + collections.enumerateObjects { (album, _, _) in + let options = PHFetchOptions() + options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)] + let assets = PHAsset.fetchAssets(in: album, options: options) + let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream + + var domainAlbum = PlatformAlbum( + id: album.localIdentifier, + name: album.localizedTitle!, + updatedAt: nil, + isCloud: isCloud, + assetCount: Int64(assets.count) + ) + + if let firstAsset = assets.firstObject { + domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) } + } + + albums.append(domainAlbum) + } + } + return albums.sorted { $0.id < $1.id } + } + + func getMediaChanges() throws -> SyncDelta { + guard #available(iOS 16, *) else { + throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) + } + + guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { + throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil) + } + + guard let storedToken = getChangeToken() else { + // No token exists, definitely need a full sync + print("MediaManager::getMediaChanges: No token found") + throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil) + } + + let currentToken = PHPhotoLibrary.shared().currentChangeToken + if storedToken == currentToken { + return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) + } + + do { + let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) + + var updatedAssets: Set = [] + var deletedAssets: Set = [] + + for change in changes { + guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } + + let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) + deletedAssets.formUnion(details.deletedLocalIdentifiers) + + if (updated.isEmpty) { continue } + + let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: nil) + for i in 0..) -> [String: [String]] { + guard !assets.isEmpty else { + return [:] + } + + var albumAssets: [String: [String]] = [:] + + for type in albumTypes { + let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) + collections.enumerateObjects { (album, _, _) in + let options = PHFetchOptions() + options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id)) + let result = PHAsset.fetchAssets(in: album, options: options) + result.enumerateObjects { (asset, _, _) in + albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier) + } + } + } + return albumAssets + } + + func getAssetIdsForAlbum(albumId: String) throws -> [String] { + let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) + guard let album = collections.firstObject else { + return [] + } + + var ids: [String] = [] + let assets = PHAsset.fetchAssets(in: album, options: nil) + assets.enumerateObjects { (asset, _, _) in + ids.append(asset.localIdentifier) + } + return ids + } + + func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 { + let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) + guard let album = collections.firstObject else { + return 0 + } + + let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp)) + let options = PHFetchOptions() + options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) + let assets = PHAsset.fetchAssets(in: album, options: options) + return Int64(assets.count) + } + + func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] { + let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) + guard let album = collections.firstObject else { + return [] + } + + let options = PHFetchOptions() + if(updatedTimeCond != nil) { + let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) + options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) + } + + let result = PHAsset.fetchAssets(in: album, options: options) + if(result.count == 0) { + return [] + } + + var assets: [PlatformAsset] = [] + result.enumerateObjects { (asset, _, _) in + assets.append(asset.toPlatformAsset()) + } + return assets + } +} diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 33683afd92f..8c95922a3ab 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -7,6 +7,7 @@ const int kLogTruncateLimit = 250; // Sync const int kSyncEventBatchSize = 5000; +const int kFetchLocalAssetsBatchSize = 40000; // Hash batch limits const int kBatchHashFileLimit = 128; diff --git a/mobile/lib/domain/interfaces/local_album.interface.dart b/mobile/lib/domain/interfaces/local_album.interface.dart new file mode 100644 index 00000000000..35cfad44552 --- /dev/null +++ b/mobile/lib/domain/interfaces/local_album.interface.dart @@ -0,0 +1,34 @@ +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'; + +abstract interface class ILocalAlbumRepository implements IDatabaseRepository { + Future> getAll({SortLocalAlbumsBy? sortBy}); + + Future> getAssetsForAlbum(String albumId); + + Future> getAssetIdsForAlbum(String albumId); + + Future upsert( + LocalAlbum album, { + Iterable toUpsert = const [], + Iterable toDelete = const [], + }); + + Future updateAll(Iterable albums); + + Future delete(String albumId); + + Future processDelta({ + required List updates, + required List deletes, + required Map> assetAlbums, + }); + + Future syncAlbumDeletes( + String albumId, + Iterable assetIdsToKeep, + ); +} + +enum SortLocalAlbumsBy { id } diff --git a/mobile/lib/domain/models/asset/asset.model.dart b/mobile/lib/domain/models/asset/asset.model.dart new file mode 100644 index 00000000000..e2bb1fc49f6 --- /dev/null +++ b/mobile/lib/domain/models/asset/asset.model.dart @@ -0,0 +1,47 @@ +part of 'base_asset.model.dart'; + +// Model for an asset stored in the server +class Asset extends BaseAsset { + final String id; + final String? localId; + + const Asset({ + required this.id, + this.localId, + required super.name, + required super.checksum, + required super.type, + required super.createdAt, + required super.updatedAt, + super.width, + super.height, + super.durationInSeconds, + super.isFavorite = false, + }); + + @override + String toString() { + return '''Asset { + id: $id, + name: $name, + type: $type, + createdAt: $createdAt, + updatedAt: $updatedAt, + width: ${width ?? ""}, + height: ${height ?? ""}, + durationInSeconds: ${durationInSeconds ?? ""}, + localId: ${localId ?? ""}, + isFavorite: $isFavorite, + }'''; + } + + @override + bool operator ==(Object other) { + if (other is! Asset) return false; + if (identical(this, other)) return true; + return super == other && id == other.id && localId == other.localId; + } + + @override + int get hashCode => super.hashCode ^ id.hashCode ^ localId.hashCode; +} diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart new file mode 100644 index 00000000000..fb954376597 --- /dev/null +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -0,0 +1,76 @@ +part 'asset.model.dart'; +part 'local_asset.model.dart'; + +enum AssetType { + // do not change this order! + other, + image, + video, + audio, +} + +sealed class BaseAsset { + final String name; + final String? checksum; + final AssetType type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final bool isFavorite; + + const BaseAsset({ + required this.name, + required this.checksum, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + this.isFavorite = false, + }); + + @override + String toString() { + return '''BaseAsset { + name: $name, + type: $type, + createdAt: $createdAt, + updatedAt: $updatedAt, + width: ${width ?? ""}, + height: ${height ?? ""}, + durationInSeconds: ${durationInSeconds ?? ""}, + isFavorite: $isFavorite, +}'''; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is BaseAsset) { + return name == other.name && + type == other.type && + createdAt == other.createdAt && + updatedAt == other.updatedAt && + width == other.width && + height == other.height && + durationInSeconds == other.durationInSeconds && + isFavorite == other.isFavorite; + } + return false; + } + + @override + int get hashCode { + return name.hashCode ^ + type.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + width.hashCode ^ + height.hashCode ^ + durationInSeconds.hashCode ^ + isFavorite.hashCode; + } +} diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart new file mode 100644 index 00000000000..25e617d8edc --- /dev/null +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -0,0 +1,74 @@ +part of 'base_asset.model.dart'; + +class LocalAsset extends BaseAsset { + final String id; + final String? remoteId; + + const LocalAsset({ + required this.id, + this.remoteId, + required super.name, + super.checksum, + required super.type, + required super.createdAt, + required super.updatedAt, + super.width, + super.height, + super.durationInSeconds, + super.isFavorite = false, + }); + + @override + String toString() { + return '''LocalAsset { + id: $id, + name: $name, + type: $type, + createdAt: $createdAt, + updatedAt: $updatedAt, + width: ${width ?? ""}, + height: ${height ?? ""}, + durationInSeconds: ${durationInSeconds ?? ""}, + remoteId: ${remoteId ?? ""} + isFavorite: $isFavorite, + }'''; + } + + @override + bool operator ==(Object other) { + if (other is! LocalAsset) return false; + if (identical(this, other)) return true; + return super == other && id == other.id && remoteId == other.remoteId; + } + + @override + int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode; + + LocalAsset copyWith({ + String? id, + String? remoteId, + String? name, + String? checksum, + AssetType? type, + DateTime? createdAt, + DateTime? updatedAt, + int? width, + int? height, + int? durationInSeconds, + bool? isFavorite, + }) { + return LocalAsset( + id: id ?? this.id, + remoteId: remoteId ?? this.remoteId, + name: name ?? this.name, + checksum: checksum ?? this.checksum, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + isFavorite: isFavorite ?? this.isFavorite, + ); + } +} diff --git a/mobile/lib/domain/models/local_album.model.dart b/mobile/lib/domain/models/local_album.model.dart new file mode 100644 index 00000000000..95c56627bbd --- /dev/null +++ b/mobile/lib/domain/models/local_album.model.dart @@ -0,0 +1,70 @@ +enum BackupSelection { + none, + selected, + excluded, +} + +class LocalAlbum { + final String id; + final String name; + final DateTime updatedAt; + + final int assetCount; + final BackupSelection backupSelection; + + const LocalAlbum({ + required this.id, + required this.name, + required this.updatedAt, + this.assetCount = 0, + this.backupSelection = BackupSelection.none, + }); + + LocalAlbum copyWith({ + String? id, + String? name, + DateTime? updatedAt, + int? assetCount, + BackupSelection? backupSelection, + }) { + return LocalAlbum( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + assetCount: assetCount ?? this.assetCount, + backupSelection: backupSelection ?? this.backupSelection, + ); + } + + @override + bool operator ==(Object other) { + if (other is! LocalAlbum) return false; + if (identical(this, other)) return true; + + return other.id == id && + other.name == name && + other.updatedAt == updatedAt && + other.assetCount == assetCount && + other.backupSelection == backupSelection; + } + + @override + int get hashCode { + return id.hashCode ^ + name.hashCode ^ + updatedAt.hashCode ^ + assetCount.hashCode ^ + backupSelection.hashCode; + } + + @override + String toString() { + return '''LocalAlbum: { +id: $id, +name: $name, +updatedAt: $updatedAt, +assetCount: $assetCount, +backupSelection: $backupSelection, +}'''; + } +} diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart new file mode 100644 index 00000000000..e07595b6dbd --- /dev/null +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -0,0 +1,379 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/interfaces/local_album.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/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart'; +import 'package:immich_mobile/utils/diff.dart'; +import 'package:logging/logging.dart'; +import 'package:platform/platform.dart'; + +class LocalSyncService { + final ILocalAlbumRepository _localAlbumRepository; + final NativeSyncApi _nativeSyncApi; + final Platform _platform; + final StoreService _storeService; + final Logger _log = Logger("DeviceSyncService"); + + LocalSyncService({ + required ILocalAlbumRepository localAlbumRepository, + required NativeSyncApi nativeSyncApi, + required StoreService storeService, + Platform? platform, + }) : _localAlbumRepository = localAlbumRepository, + _nativeSyncApi = nativeSyncApi, + _storeService = storeService, + _platform = platform ?? const LocalPlatform(); + + bool get _ignoreIcloudAssets => + _storeService.get(StoreKey.ignoreIcloudAssets, false) == true; + + Future sync({bool full = false}) async { + final Stopwatch stopwatch = Stopwatch()..start(); + try { + if (full || await _nativeSyncApi.shouldFullSync()) { + _log.fine("Full sync request from ${full ? "user" : "native"}"); + DLog.log("Full sync request from ${full ? "user" : "native"}"); + return await fullSync(); + } + + final delta = await _nativeSyncApi.getMediaChanges(); + if (!delta.hasChanges) { + _log.fine("No media changes detected. Skipping sync"); + DLog.log("No media changes detected. Skipping sync"); + return; + } + + DLog.log("Delta updated: ${delta.updates.length}"); + DLog.log("Delta deleted: ${delta.deletes.length}"); + + final deviceAlbums = await _nativeSyncApi.getAlbums(); + await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums()); + await _localAlbumRepository.processDelta( + updates: delta.updates.toLocalAssets(), + deletes: delta.deletes, + assetAlbums: delta.assetAlbums, + ); + + final dbAlbums = await _localAlbumRepository.getAll(); + // On Android, we need to sync all albums since it is not possible to + // detect album deletions from the native side + if (_platform.isAndroid) { + for (final album in dbAlbums) { + final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id); + await _localAlbumRepository.syncAlbumDeletes(album.id, deviceIds); + } + } + + if (_platform.isIOS) { + // On iOS, we need to full sync albums that are marked as cloud as the delta sync + // does not include changes for cloud albums. If ignoreIcloudAssets is enabled, + // remove the albums from the local database from the previous sync + final cloudAlbums = + deviceAlbums.where((a) => a.isCloud).toLocalAlbums(); + for (final album in cloudAlbums) { + final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id); + if (dbAlbum == null) { + _log.warning( + "Cloud album ${album.name} not found in local database. Skipping sync.", + ); + continue; + } + if (_ignoreIcloudAssets) { + await removeAlbum(dbAlbum); + } else { + await updateAlbum(dbAlbum, album); + } + } + } + + await _nativeSyncApi.checkpointSync(); + } catch (e, s) { + _log.severe("Error performing device sync", e, s); + } finally { + stopwatch.stop(); + _log.info("Device sync took - ${stopwatch.elapsedMilliseconds}ms"); + DLog.log("Device sync took - ${stopwatch.elapsedMilliseconds}ms"); + } + } + + Future fullSync() async { + try { + final Stopwatch stopwatch = Stopwatch()..start(); + + List deviceAlbums = + List.of(await _nativeSyncApi.getAlbums()); + if (_platform.isIOS && _ignoreIcloudAssets) { + deviceAlbums.removeWhere((album) => album.isCloud); + } + + final dbAlbums = + await _localAlbumRepository.getAll(sortBy: SortLocalAlbumsBy.id); + + await diffSortedLists( + dbAlbums, + deviceAlbums.toLocalAlbums(), + compare: (a, b) => a.id.compareTo(b.id), + both: updateAlbum, + onlyFirst: removeAlbum, + onlySecond: addAlbum, + ); + + await _nativeSyncApi.checkpointSync(); + stopwatch.stop(); + _log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms"); + DLog.log("Full device sync took - ${stopwatch.elapsedMilliseconds}ms"); + } catch (e, s) { + _log.severe("Error performing full device sync", e, s); + } + } + + Future addAlbum(LocalAlbum album) async { + try { + _log.fine("Adding device album ${album.name}"); + + final assets = album.assetCount > 0 + ? await _nativeSyncApi.getAssetsForAlbum(album.id) + : []; + + await _localAlbumRepository.upsert( + album, + toUpsert: assets.toLocalAssets(), + ); + _log.fine("Successfully added device album ${album.name}"); + } catch (e, s) { + _log.warning("Error while adding device album", e, s); + } + } + + Future removeAlbum(LocalAlbum a) async { + _log.fine("Removing device album ${a.name}"); + try { + // Asset deletion is handled in the repository + await _localAlbumRepository.delete(a.id); + } catch (e, s) { + _log.warning("Error while removing device album", e, s); + } + } + + // The deviceAlbum is ignored since we are going to refresh it anyways + FutureOr updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async { + try { + _log.fine("Syncing device album ${dbAlbum.name}"); + + if (_albumsEqual(deviceAlbum, dbAlbum)) { + _log.fine( + "Device album ${dbAlbum.name} has not changed. Skipping sync.", + ); + return false; + } + + _log.fine("Device album ${dbAlbum.name} has changed. Syncing..."); + + // Faster path - only new assets added + if (await checkAddition(dbAlbum, deviceAlbum)) { + _log.fine("Fast synced device album ${dbAlbum.name}"); + DLog.log("Fast synced device album ${dbAlbum.name}"); + return true; + } + + // Slower path - full sync + return await fullDiff(dbAlbum, deviceAlbum); + } catch (e, s) { + _log.warning("Error while diff device album", e, s); + } + return true; + } + + @visibleForTesting + // The [deviceAlbum] is expected to be refreshed before calling this method + // with modified time and asset count + Future checkAddition( + LocalAlbum dbAlbum, + LocalAlbum deviceAlbum, + ) async { + try { + _log.fine("Fast syncing device album ${dbAlbum.name}"); + // Assets has been modified + if (deviceAlbum.assetCount <= dbAlbum.assetCount) { + _log.fine("Local album has modifications. Proceeding to full sync"); + return false; + } + + final updatedTime = + (dbAlbum.updatedAt.millisecondsSinceEpoch ~/ 1000) + 1; + final newAssetsCount = + await _nativeSyncApi.getAssetsCountSince(deviceAlbum.id, updatedTime); + + // Early return if no new assets were found + if (newAssetsCount == 0) { + _log.fine( + "No new assets found despite album having changes. Proceeding to full sync for ${dbAlbum.name}", + ); + return false; + } + + // Check whether there is only addition or if there has been deletions + if (deviceAlbum.assetCount != dbAlbum.assetCount + newAssetsCount) { + _log.fine("Local album has modifications. Proceeding to full sync"); + return false; + } + + final newAssets = await _nativeSyncApi.getAssetsForAlbum( + deviceAlbum.id, + updatedTimeCond: updatedTime, + ); + + await _localAlbumRepository.upsert( + deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection), + toUpsert: newAssets.toLocalAssets(), + ); + + return true; + } catch (e, s) { + _log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s); + } + return false; + } + + @visibleForTesting + // The [deviceAlbum] is expected to be refreshed before calling this method + // with modified time and asset count + Future fullDiff(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async { + try { + final assetsInDevice = deviceAlbum.assetCount > 0 + ? await _nativeSyncApi + .getAssetsForAlbum(deviceAlbum.id) + .then((a) => a.toLocalAssets()) + : []; + final assetsInDb = dbAlbum.assetCount > 0 + ? await _localAlbumRepository.getAssetsForAlbum(dbAlbum.id) + : []; + + if (deviceAlbum.assetCount == 0) { + _log.fine( + "Device album ${deviceAlbum.name} is empty. Removing assets from DB.", + ); + await _localAlbumRepository.upsert( + deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection), + toDelete: assetsInDb.map((a) => a.id), + ); + return true; + } + + final updatedDeviceAlbum = deviceAlbum.copyWith( + backupSelection: dbAlbum.backupSelection, + ); + + if (dbAlbum.assetCount == 0) { + _log.fine( + "Device album ${deviceAlbum.name} is empty. Adding assets to DB.", + ); + await _localAlbumRepository.upsert( + updatedDeviceAlbum, + toUpsert: assetsInDevice, + ); + return true; + } + + assert(assetsInDb.isSortedBy((a) => a.id)); + assetsInDevice.sort((a, b) => a.id.compareTo(b.id)); + + final assetsToUpsert = []; + final assetsToDelete = []; + + diffSortedListsSync( + assetsInDb, + assetsInDevice, + compare: (a, b) => a.id.compareTo(b.id), + both: (dbAsset, deviceAsset) { + // Custom comparison to check if the asset has been modified without + // comparing the checksum + if (!_assetsEqual(dbAsset, deviceAsset)) { + assetsToUpsert.add(deviceAsset); + return true; + } + return false; + }, + onlyFirst: (dbAsset) => assetsToDelete.add(dbAsset.id), + onlySecond: (deviceAsset) => assetsToUpsert.add(deviceAsset), + ); + + _log.fine( + "Syncing ${deviceAlbum.name}. ${assetsToUpsert.length} assets to add/update and ${assetsToDelete.length} assets to delete", + ); + + if (assetsToUpsert.isEmpty && assetsToDelete.isEmpty) { + _log.fine( + "No asset changes detected in album ${deviceAlbum.name}. Updating metadata.", + ); + _localAlbumRepository.upsert(updatedDeviceAlbum); + return true; + } + + await _localAlbumRepository.upsert( + updatedDeviceAlbum, + toUpsert: assetsToUpsert, + toDelete: assetsToDelete, + ); + + return true; + } catch (e, s) { + _log.warning("Error on full syncing local album: ${dbAlbum.name}", e, s); + } + return true; + } + + bool _assetsEqual(LocalAsset a, LocalAsset b) { + return a.updatedAt.isAtSameMomentAs(b.updatedAt) && + a.createdAt.isAtSameMomentAs(b.createdAt) && + a.width == b.width && + a.height == b.height && + a.durationInSeconds == b.durationInSeconds; + } + + bool _albumsEqual(LocalAlbum a, LocalAlbum b) { + return a.name == b.name && + a.assetCount == b.assetCount && + a.updatedAt.isAtSameMomentAs(b.updatedAt); + } +} + +extension on Iterable { + List toLocalAlbums() { + return map( + (e) => LocalAlbum( + id: e.id, + name: e.name, + updatedAt: e.updatedAt == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000), + assetCount: e.assetCount, + ), + ).toList(); + } +} + +extension on Iterable { + List toLocalAssets() { + return map( + (e) => LocalAsset( + id: e.id, + name: e.name, + type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other, + createdAt: e.createdAt == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000), + updatedAt: e.updatedAt == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000), + durationInSeconds: e.durationInSeconds, + ), + ).toList(); + } +} diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index f63dc81ba99..6a694ee44ac 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -1,13 +1,12 @@ -// ignore_for_file: avoid-passing-async-when-sync-expected - import 'dart:async'; -import 'package:immich_mobile/providers/infrastructure/sync_stream.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/utils/isolate.dart'; import 'package:worker_manager/worker_manager.dart'; class BackgroundSyncManager { Cancelable? _syncTask; + Cancelable? _deviceAlbumSyncTask; BackgroundSyncManager(); @@ -23,7 +22,30 @@ class BackgroundSyncManager { return Future.wait(futures); } - Future sync() { + // No need to cancel the task, as it can also be run when the user logs out + Future syncLocal({bool full = false}) { + if (_deviceAlbumSyncTask != null) { + return _deviceAlbumSyncTask!.future; + } + + // We use a ternary operator to avoid [_deviceAlbumSyncTask] from being + // captured by the closure passed to [runInIsolateGentle]. + _deviceAlbumSyncTask = full + ? runInIsolateGentle( + computation: (ref) => + ref.read(localSyncServiceProvider).sync(full: true), + ) + : runInIsolateGentle( + computation: (ref) => + ref.read(localSyncServiceProvider).sync(full: false), + ); + + return _deviceAlbumSyncTask!.whenComplete(() { + _deviceAlbumSyncTask = null; + }); + } + + Future syncRemote() { if (_syncTask != null) { return _syncTask!.future; } @@ -31,9 +53,8 @@ class BackgroundSyncManager { _syncTask = runInIsolateGentle( computation: (ref) => ref.read(syncStreamServiceProvider).sync(), ); - _syncTask!.whenComplete(() { + return _syncTask!.whenComplete(() { _syncTask = null; }); - return _syncTask!.future; } } diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart new file mode 100644 index 00000000000..74c3e7a8f73 --- /dev/null +++ b/mobile/lib/infrastructure/entities/local_album.entity.dart @@ -0,0 +1,18 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class LocalAlbumEntity extends Table with DriftDefaultsMixin { + const LocalAlbumEntity(); + + TextColumn get id => text()(); + TextColumn get name => text()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + IntColumn get backupSelection => intEnum()(); + + // Used for mark & sweep + BoolColumn get marker_ => boolean().nullable()(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile/lib/infrastructure/entities/local_album.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart new file mode 100644 index 00000000000..5955742ec0e --- /dev/null +++ b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart @@ -0,0 +1,497 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart' + as i1; +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; + +typedef $$LocalAlbumEntityTableCreateCompanionBuilder + = i1.LocalAlbumEntityCompanion Function({ + required String id, + required String name, + i0.Value updatedAt, + required i2.BackupSelection backupSelection, + i0.Value marker_, +}); +typedef $$LocalAlbumEntityTableUpdateCompanionBuilder + = i1.LocalAlbumEntityCompanion Function({ + i0.Value id, + i0.Value name, + i0.Value updatedAt, + i0.Value backupSelection, + i0.Value marker_, +}); + +class $$LocalAlbumEntityTableFilterComposer + extends i0.Composer { + $$LocalAlbumEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get name => $composableBuilder( + column: $table.name, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnWithTypeConverterFilters + get backupSelection => $composableBuilder( + column: $table.backupSelection, + builder: (column) => i0.ColumnWithTypeConverterFilters(column)); + + i0.ColumnFilters get marker_ => $composableBuilder( + column: $table.marker_, builder: (column) => i0.ColumnFilters(column)); +} + +class $$LocalAlbumEntityTableOrderingComposer + extends i0.Composer { + $$LocalAlbumEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get name => $composableBuilder( + column: $table.name, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get backupSelection => $composableBuilder( + column: $table.backupSelection, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get marker_ => $composableBuilder( + column: $table.marker_, builder: (column) => i0.ColumnOrderings(column)); +} + +class $$LocalAlbumEntityTableAnnotationComposer + extends i0.Composer { + $$LocalAlbumEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + i0.GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter + get backupSelection => $composableBuilder( + column: $table.backupSelection, builder: (column) => column); + + i0.GeneratedColumn get marker_ => + $composableBuilder(column: $table.marker_, builder: (column) => column); +} + +class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager< + i0.GeneratedDatabase, + i1.$LocalAlbumEntityTable, + i1.LocalAlbumEntityData, + i1.$$LocalAlbumEntityTableFilterComposer, + i1.$$LocalAlbumEntityTableOrderingComposer, + i1.$$LocalAlbumEntityTableAnnotationComposer, + $$LocalAlbumEntityTableCreateCompanionBuilder, + $$LocalAlbumEntityTableUpdateCompanionBuilder, + ( + i1.LocalAlbumEntityData, + i0.BaseReferences + ), + i1.LocalAlbumEntityData, + i0.PrefetchHooks Function()> { + $$LocalAlbumEntityTableTableManager( + i0.GeneratedDatabase db, i1.$LocalAlbumEntityTable table) + : super(i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$LocalAlbumEntityTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => i1 + .$$LocalAlbumEntityTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$$LocalAlbumEntityTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + i0.Value id = const i0.Value.absent(), + i0.Value name = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value backupSelection = + const i0.Value.absent(), + i0.Value marker_ = const i0.Value.absent(), + }) => + i1.LocalAlbumEntityCompanion( + id: id, + name: name, + updatedAt: updatedAt, + backupSelection: backupSelection, + marker_: marker_, + ), + createCompanionCallback: ({ + required String id, + required String name, + i0.Value updatedAt = const i0.Value.absent(), + required i2.BackupSelection backupSelection, + i0.Value marker_ = const i0.Value.absent(), + }) => + i1.LocalAlbumEntityCompanion.insert( + id: id, + name: name, + updatedAt: updatedAt, + backupSelection: backupSelection, + marker_: marker_, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$LocalAlbumEntityTableProcessedTableManager = i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$LocalAlbumEntityTable, + i1.LocalAlbumEntityData, + i1.$$LocalAlbumEntityTableFilterComposer, + i1.$$LocalAlbumEntityTableOrderingComposer, + i1.$$LocalAlbumEntityTableAnnotationComposer, + $$LocalAlbumEntityTableCreateCompanionBuilder, + $$LocalAlbumEntityTableUpdateCompanionBuilder, + ( + i1.LocalAlbumEntityData, + i0.BaseReferences + ), + i1.LocalAlbumEntityData, + i0.PrefetchHooks Function()>; + +class $LocalAlbumEntityTable extends i3.LocalAlbumEntity + with i0.TableInfo<$LocalAlbumEntityTable, i1.LocalAlbumEntityData> { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $LocalAlbumEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id'); + @override + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); + static const i0.VerificationMeta _nameMeta = + const i0.VerificationMeta('name'); + @override + late final i0.GeneratedColumn name = i0.GeneratedColumn( + 'name', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); + static const i0.VerificationMeta _updatedAtMeta = + const i0.VerificationMeta('updatedAt'); + @override + late final i0.GeneratedColumn updatedAt = + i0.GeneratedColumn('updated_at', aliasedName, false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: i4.currentDateAndTime); + @override + late final i0.GeneratedColumnWithTypeConverter + backupSelection = i0.GeneratedColumn( + 'backup_selection', aliasedName, false, + type: i0.DriftSqlType.int, requiredDuringInsert: true) + .withConverter( + i1.$LocalAlbumEntityTable.$converterbackupSelection); + static const i0.VerificationMeta _marker_Meta = + const i0.VerificationMeta('marker_'); + @override + late final i0.GeneratedColumn marker_ = i0.GeneratedColumn( + 'marker', aliasedName, true, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + i0.GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))')); + @override + List get $columns => + [id, name, updatedAt, backupSelection, marker_]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, + {bool isInserting = false}) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + } + if (data.containsKey('marker')) { + context.handle(_marker_Meta, + marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + i1.LocalAlbumEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.LocalAlbumEntityData( + id: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}name'])!, + updatedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + 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']), + ); + } + + @override + $LocalAlbumEntityTable createAlias(String alias) { + return $LocalAlbumEntityTable(attachedDatabase, alias); + } + + static i0.JsonTypeConverter2 + $converterbackupSelection = + const i0.EnumIndexConverter( + i2.BackupSelection.values); + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumEntityData extends i0.DataClass + implements i0.Insertable { + final String id; + final String name; + final DateTime updatedAt; + final i2.BackupSelection backupSelection; + final bool? marker_; + const LocalAlbumEntityData( + {required this.id, + required this.name, + required this.updatedAt, + required this.backupSelection, + this.marker_}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['name'] = i0.Variable(name); + map['updated_at'] = i0.Variable(updatedAt); + { + map['backup_selection'] = i0.Variable(i1 + .$LocalAlbumEntityTable.$converterbackupSelection + .toSql(backupSelection)); + } + if (!nullToAbsent || marker_ != null) { + map['marker'] = i0.Variable(marker_); + } + return map; + } + + factory LocalAlbumEntityData.fromJson(Map json, + {i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return LocalAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + updatedAt: serializer.fromJson(json['updatedAt']), + backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection + .fromJson(serializer.fromJson(json['backupSelection'])), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'updatedAt': serializer.toJson(updatedAt), + 'backupSelection': serializer.toJson(i1 + .$LocalAlbumEntityTable.$converterbackupSelection + .toJson(backupSelection)), + 'marker_': serializer.toJson(marker_), + }; + } + + i1.LocalAlbumEntityData copyWith( + {String? id, + String? name, + DateTime? updatedAt, + i2.BackupSelection? backupSelection, + i0.Value marker_ = const i0.Value.absent()}) => + i1.LocalAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + marker_: marker_.present ? marker_.value : 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, + backupSelection: data.backupSelection.present + ? data.backupSelection.value + : this.backupSelection, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, name, updatedAt, backupSelection, marker_); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.LocalAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.updatedAt == this.updatedAt && + other.backupSelection == this.backupSelection && + other.marker_ == this.marker_); +} + +class LocalAlbumEntityCompanion + extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value name; + final i0.Value updatedAt; + final i0.Value backupSelection; + final i0.Value marker_; + const LocalAlbumEntityCompanion({ + this.id = const i0.Value.absent(), + this.name = const i0.Value.absent(), + this.updatedAt = 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(), + required i2.BackupSelection backupSelection, + this.marker_ = const i0.Value.absent(), + }) : id = i0.Value(id), + name = i0.Value(name), + backupSelection = i0.Value(backupSelection); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? name, + i0.Expression? updatedAt, + i0.Expression? backupSelection, + i0.Expression? marker_, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (updatedAt != null) 'updated_at': updatedAt, + if (backupSelection != null) 'backup_selection': backupSelection, + if (marker_ != null) 'marker': marker_, + }); + } + + i1.LocalAlbumEntityCompanion copyWith( + {i0.Value? id, + i0.Value? name, + i0.Value? updatedAt, + i0.Value? backupSelection, + i0.Value? marker_}) { + return i1.LocalAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (name.present) { + map['name'] = i0.Variable(name.value); + } + if (updatedAt.present) { + map['updated_at'] = i0.Variable(updatedAt.value); + } + if (backupSelection.present) { + map['backup_selection'] = i0.Variable(i1 + .$LocalAlbumEntityTable.$converterbackupSelection + .toSql(backupSelection.value)); + } + if (marker_.present) { + map['marker'] = i0.Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart new file mode 100644 index 00000000000..b64b9ec2fbe --- /dev/null +++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart @@ -0,0 +1,17 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin { + const LocalAlbumAssetEntity(); + + TextColumn get assetId => + text().references(LocalAssetEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get albumId => + text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)(); + + @override + Set get primaryKey => {assetId, albumId}; +} diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart new file mode 100644 index 00000000000..e8f94fa74b4 --- /dev/null +++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart @@ -0,0 +1,565 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart' + as i1; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart' + as i2; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' + as i3; +import 'package:drift/internal/modular.dart' as i4; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart' + as i5; + +typedef $$LocalAlbumAssetEntityTableCreateCompanionBuilder + = i1.LocalAlbumAssetEntityCompanion Function({ + required String assetId, + required String albumId, +}); +typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder + = i1.LocalAlbumAssetEntityCompanion Function({ + i0.Value assetId, + i0.Value albumId, +}); + +final class $$LocalAlbumAssetEntityTableReferences extends i0.BaseReferences< + i0.GeneratedDatabase, + i1.$LocalAlbumAssetEntityTable, + i1.LocalAlbumAssetEntityData> { + $$LocalAlbumAssetEntityTableReferences( + super.$_db, super.$_table, super.$_typedResult); + + static i3.$LocalAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => + i4.ReadDatabaseContainer(db) + .resultSet('local_asset_entity') + .createAlias(i0.$_aliasNameGenerator( + i4.ReadDatabaseContainer(db) + .resultSet( + 'local_album_asset_entity') + .assetId, + i4.ReadDatabaseContainer(db) + .resultSet('local_asset_entity') + .id)); + + i3.$$LocalAssetEntityTableProcessedTableManager get assetId { + final $_column = $_itemColumn('asset_id')!; + + final manager = i3 + .$$LocalAssetEntityTableTableManager( + $_db, + i4.ReadDatabaseContainer($_db) + .resultSet('local_asset_entity')) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_assetIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } + + static i5.$LocalAlbumEntityTable _albumIdTable(i0.GeneratedDatabase db) => + i4.ReadDatabaseContainer(db) + .resultSet('local_album_entity') + .createAlias(i0.$_aliasNameGenerator( + i4.ReadDatabaseContainer(db) + .resultSet( + 'local_album_asset_entity') + .albumId, + i4.ReadDatabaseContainer(db) + .resultSet('local_album_entity') + .id)); + + i5.$$LocalAlbumEntityTableProcessedTableManager get albumId { + final $_column = $_itemColumn('album_id')!; + + final manager = i5 + .$$LocalAlbumEntityTableTableManager( + $_db, + i4.ReadDatabaseContainer($_db) + .resultSet('local_album_entity')) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_albumIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $$LocalAlbumAssetEntityTableFilterComposer + extends i0.Composer { + $$LocalAlbumAssetEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i3.$$LocalAssetEntityTableFilterComposer get assetId { + final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('local_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i3.$$LocalAssetEntityTableFilterComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet('local_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + + i5.$$LocalAlbumEntityTableFilterComposer get albumId { + final i5.$$LocalAlbumEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.albumId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('local_album_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i5.$$LocalAlbumEntityTableFilterComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet('local_album_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$LocalAlbumAssetEntityTableOrderingComposer + extends i0.Composer { + $$LocalAlbumAssetEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i3.$$LocalAssetEntityTableOrderingComposer get assetId { + final i3.$$LocalAssetEntityTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('local_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i3.$$LocalAssetEntityTableOrderingComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet( + 'local_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + + i5.$$LocalAlbumEntityTableOrderingComposer get albumId { + final i5.$$LocalAlbumEntityTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.albumId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('local_album_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i5.$$LocalAlbumEntityTableOrderingComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet( + 'local_album_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$LocalAlbumAssetEntityTableAnnotationComposer + extends i0.Composer { + $$LocalAlbumAssetEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i3.$$LocalAssetEntityTableAnnotationComposer get assetId { + final i3.$$LocalAssetEntityTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('local_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i3.$$LocalAssetEntityTableAnnotationComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet( + 'local_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + + i5.$$LocalAlbumEntityTableAnnotationComposer get albumId { + final i5.$$LocalAlbumEntityTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.albumId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('local_album_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i5.$$LocalAlbumEntityTableAnnotationComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet( + 'local_album_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$LocalAlbumAssetEntityTableTableManager extends i0.RootTableManager< + i0.GeneratedDatabase, + i1.$LocalAlbumAssetEntityTable, + i1.LocalAlbumAssetEntityData, + i1.$$LocalAlbumAssetEntityTableFilterComposer, + i1.$$LocalAlbumAssetEntityTableOrderingComposer, + i1.$$LocalAlbumAssetEntityTableAnnotationComposer, + $$LocalAlbumAssetEntityTableCreateCompanionBuilder, + $$LocalAlbumAssetEntityTableUpdateCompanionBuilder, + (i1.LocalAlbumAssetEntityData, i1.$$LocalAlbumAssetEntityTableReferences), + i1.LocalAlbumAssetEntityData, + i0.PrefetchHooks Function({bool assetId, bool albumId})> { + $$LocalAlbumAssetEntityTableTableManager( + i0.GeneratedDatabase db, i1.$LocalAlbumAssetEntityTable table) + : super(i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$LocalAlbumAssetEntityTableFilterComposer( + $db: db, $table: table), + createOrderingComposer: () => + i1.$$LocalAlbumAssetEntityTableOrderingComposer( + $db: db, $table: table), + createComputedFieldComposer: () => + i1.$$LocalAlbumAssetEntityTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + i0.Value assetId = const i0.Value.absent(), + i0.Value albumId = const i0.Value.absent(), + }) => + i1.LocalAlbumAssetEntityCompanion( + assetId: assetId, + albumId: albumId, + ), + createCompanionCallback: ({ + required String assetId, + required String albumId, + }) => + i1.LocalAlbumAssetEntityCompanion.insert( + assetId: assetId, + albumId: albumId, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable(table), + i1.$$LocalAlbumAssetEntityTableReferences(db, table, e) + )) + .toList(), + prefetchHooksCallback: ({assetId = false, albumId = 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 (assetId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.assetId, + referencedTable: i1.$$LocalAlbumAssetEntityTableReferences + ._assetIdTable(db), + referencedColumn: i1.$$LocalAlbumAssetEntityTableReferences + ._assetIdTable(db) + .id, + ) as T; + } + if (albumId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.albumId, + referencedTable: i1.$$LocalAlbumAssetEntityTableReferences + ._albumIdTable(db), + referencedColumn: i1.$$LocalAlbumAssetEntityTableReferences + ._albumIdTable(db) + .id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + )); +} + +typedef $$LocalAlbumAssetEntityTableProcessedTableManager + = i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$LocalAlbumAssetEntityTable, + i1.LocalAlbumAssetEntityData, + i1.$$LocalAlbumAssetEntityTableFilterComposer, + i1.$$LocalAlbumAssetEntityTableOrderingComposer, + i1.$$LocalAlbumAssetEntityTableAnnotationComposer, + $$LocalAlbumAssetEntityTableCreateCompanionBuilder, + $$LocalAlbumAssetEntityTableUpdateCompanionBuilder, + ( + i1.LocalAlbumAssetEntityData, + i1.$$LocalAlbumAssetEntityTableReferences + ), + i1.LocalAlbumAssetEntityData, + i0.PrefetchHooks Function({bool assetId, bool albumId})>; + +class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity + with + i0 + .TableInfo<$LocalAlbumAssetEntityTable, i1.LocalAlbumAssetEntityData> { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $LocalAlbumAssetEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _assetIdMeta = + const i0.VerificationMeta('assetId'); + @override + late final i0.GeneratedColumn assetId = i0.GeneratedColumn( + 'asset_id', aliasedName, false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES local_asset_entity (id) ON DELETE CASCADE')); + static const i0.VerificationMeta _albumIdMeta = + const i0.VerificationMeta('albumId'); + @override + late final i0.GeneratedColumn albumId = i0.GeneratedColumn( + 'album_id', aliasedName, false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES local_album_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_asset_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, + {bool isInserting = false}) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('asset_id')) { + context.handle(_assetIdMeta, + assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta)); + } else if (isInserting) { + context.missing(_assetIdMeta); + } + if (data.containsKey('album_id')) { + context.handle(_albumIdMeta, + albumId.isAcceptableOrUnknown(data['album_id']!, _albumIdMeta)); + } else if (isInserting) { + context.missing(_albumIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {assetId, albumId}; + @override + i1.LocalAlbumAssetEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.LocalAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + albumId: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}album_id'])!, + ); + } + + @override + $LocalAlbumAssetEntityTable createAlias(String alias) { + return $LocalAlbumAssetEntityTable(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumAssetEntityData extends i0.DataClass + implements i0.Insertable { + final String assetId; + final String albumId; + const LocalAlbumAssetEntityData( + {required this.assetId, required this.albumId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = i0.Variable(assetId); + map['album_id'] = i0.Variable(albumId); + return map; + } + + factory LocalAlbumAssetEntityData.fromJson(Map json, + {i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return LocalAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + i1.LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + i1.LocalAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + LocalAlbumAssetEntityData copyWithCompanion( + i1.LocalAlbumAssetEntityCompanion data) { + return LocalAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.LocalAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class LocalAlbumAssetEntityCompanion + extends i0.UpdateCompanion { + final i0.Value assetId; + final i0.Value albumId; + const LocalAlbumAssetEntityCompanion({ + this.assetId = const i0.Value.absent(), + this.albumId = const i0.Value.absent(), + }); + LocalAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = i0.Value(assetId), + albumId = i0.Value(albumId); + static i0.Insertable custom({ + i0.Expression? assetId, + i0.Expression? albumId, + }) { + return i0.RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + i1.LocalAlbumAssetEntityCompanion copyWith( + {i0.Value? assetId, i0.Value? albumId}) { + return i1.LocalAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = i0.Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = i0.Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart new file mode 100644 index 00000000000..724cf532c59 --- /dev/null +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -0,0 +1,17 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +@TableIndex(name: 'local_asset_checksum', columns: {#checksum}) +class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { + const LocalAssetEntity(); + + TextColumn get id => text()(); + TextColumn get checksum => text().nullable()(); + + // Only used during backup to mirror the favorite status of the asset in the server + BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart new file mode 100644 index 00000000000..0a4896a4a3a --- /dev/null +++ b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart @@ -0,0 +1,658 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' + as i1; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart' + as i3; +import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4; + +typedef $$LocalAssetEntityTableCreateCompanionBuilder + = i1.LocalAssetEntityCompanion Function({ + required String name, + required i2.AssetType type, + i0.Value createdAt, + i0.Value updatedAt, + i0.Value durationInSeconds, + required String id, + i0.Value checksum, + i0.Value isFavorite, +}); +typedef $$LocalAssetEntityTableUpdateCompanionBuilder + = i1.LocalAssetEntityCompanion Function({ + i0.Value name, + i0.Value type, + i0.Value createdAt, + i0.Value updatedAt, + i0.Value durationInSeconds, + i0.Value id, + i0.Value checksum, + i0.Value isFavorite, +}); + +class $$LocalAssetEntityTableFilterComposer + extends i0.Composer { + $$LocalAssetEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get name => $composableBuilder( + column: $table.name, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnWithTypeConverterFilters get type => + $composableBuilder( + column: $table.type, + builder: (column) => i0.ColumnWithTypeConverterFilters(column)); + + i0.ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get durationInSeconds => $composableBuilder( + column: $table.durationInSeconds, + builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get checksum => $composableBuilder( + column: $table.checksum, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get isFavorite => $composableBuilder( + column: $table.isFavorite, builder: (column) => i0.ColumnFilters(column)); +} + +class $$LocalAssetEntityTableOrderingComposer + extends i0.Composer { + $$LocalAssetEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get name => $composableBuilder( + column: $table.name, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get type => $composableBuilder( + column: $table.type, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get durationInSeconds => $composableBuilder( + column: $table.durationInSeconds, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get checksum => $composableBuilder( + column: $table.checksum, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get isFavorite => $composableBuilder( + column: $table.isFavorite, + builder: (column) => i0.ColumnOrderings(column)); +} + +class $$LocalAssetEntityTableAnnotationComposer + extends i0.Composer { + $$LocalAssetEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get type => + $composableBuilder(column: $table.type, builder: (column) => column); + + i0.GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + i0.GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + i0.GeneratedColumn get durationInSeconds => $composableBuilder( + column: $table.durationInSeconds, builder: (column) => column); + + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumn get checksum => + $composableBuilder(column: $table.checksum, builder: (column) => column); + + i0.GeneratedColumn get isFavorite => $composableBuilder( + column: $table.isFavorite, builder: (column) => column); +} + +class $$LocalAssetEntityTableTableManager extends i0.RootTableManager< + i0.GeneratedDatabase, + i1.$LocalAssetEntityTable, + i1.LocalAssetEntityData, + i1.$$LocalAssetEntityTableFilterComposer, + i1.$$LocalAssetEntityTableOrderingComposer, + i1.$$LocalAssetEntityTableAnnotationComposer, + $$LocalAssetEntityTableCreateCompanionBuilder, + $$LocalAssetEntityTableUpdateCompanionBuilder, + ( + i1.LocalAssetEntityData, + i0.BaseReferences + ), + i1.LocalAssetEntityData, + i0.PrefetchHooks Function()> { + $$LocalAssetEntityTableTableManager( + i0.GeneratedDatabase db, i1.$LocalAssetEntityTable table) + : super(i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$LocalAssetEntityTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => i1 + .$$LocalAssetEntityTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$$LocalAssetEntityTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + i0.Value name = const i0.Value.absent(), + i0.Value type = const i0.Value.absent(), + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value durationInSeconds = const i0.Value.absent(), + i0.Value id = const i0.Value.absent(), + i0.Value checksum = const i0.Value.absent(), + i0.Value isFavorite = const i0.Value.absent(), + }) => + i1.LocalAssetEntityCompanion( + name: name, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds, + id: id, + checksum: checksum, + isFavorite: isFavorite, + ), + createCompanionCallback: ({ + required String name, + required i2.AssetType type, + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value durationInSeconds = const i0.Value.absent(), + required String id, + i0.Value checksum = const i0.Value.absent(), + i0.Value isFavorite = const i0.Value.absent(), + }) => + i1.LocalAssetEntityCompanion.insert( + name: name, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds, + id: id, + checksum: checksum, + isFavorite: isFavorite, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$LocalAssetEntityTableProcessedTableManager = i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$LocalAssetEntityTable, + i1.LocalAssetEntityData, + i1.$$LocalAssetEntityTableFilterComposer, + i1.$$LocalAssetEntityTableOrderingComposer, + i1.$$LocalAssetEntityTableAnnotationComposer, + $$LocalAssetEntityTableCreateCompanionBuilder, + $$LocalAssetEntityTableUpdateCompanionBuilder, + ( + i1.LocalAssetEntityData, + i0.BaseReferences + ), + i1.LocalAssetEntityData, + i0.PrefetchHooks Function()>; +i0.Index get localAssetChecksum => i0.Index('local_asset_checksum', + 'CREATE INDEX local_asset_checksum ON local_asset_entity (checksum)'); + +class $LocalAssetEntityTable extends i3.LocalAssetEntity + with i0.TableInfo<$LocalAssetEntityTable, i1.LocalAssetEntityData> { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $LocalAssetEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _nameMeta = + const i0.VerificationMeta('name'); + @override + late final i0.GeneratedColumn name = i0.GeneratedColumn( + 'name', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); + @override + late final i0.GeneratedColumnWithTypeConverter type = + i0.GeneratedColumn('type', aliasedName, false, + type: i0.DriftSqlType.int, requiredDuringInsert: true) + .withConverter( + i1.$LocalAssetEntityTable.$convertertype); + static const i0.VerificationMeta _createdAtMeta = + const i0.VerificationMeta('createdAt'); + @override + late final i0.GeneratedColumn createdAt = + i0.GeneratedColumn('created_at', aliasedName, false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: i4.currentDateAndTime); + static const i0.VerificationMeta _updatedAtMeta = + const i0.VerificationMeta('updatedAt'); + @override + late final i0.GeneratedColumn updatedAt = + i0.GeneratedColumn('updated_at', aliasedName, false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: i4.currentDateAndTime); + static const i0.VerificationMeta _durationInSecondsMeta = + const i0.VerificationMeta('durationInSeconds'); + @override + late final i0.GeneratedColumn durationInSeconds = + i0.GeneratedColumn('duration_in_seconds', aliasedName, true, + type: i0.DriftSqlType.int, requiredDuringInsert: false); + static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id'); + @override + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); + static const i0.VerificationMeta _checksumMeta = + const i0.VerificationMeta('checksum'); + @override + late final i0.GeneratedColumn checksum = i0.GeneratedColumn( + 'checksum', aliasedName, true, + type: i0.DriftSqlType.string, requiredDuringInsert: false); + static const i0.VerificationMeta _isFavoriteMeta = + const i0.VerificationMeta('isFavorite'); + @override + late final i0.GeneratedColumn isFavorite = i0.GeneratedColumn( + 'is_favorite', aliasedName, false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))'), + defaultValue: const i4.Constant(false)); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + durationInSeconds, + id, + checksum, + isFavorite + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_asset_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, + {bool isInserting = false}) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('name')) { + context.handle( + _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + } + if (data.containsKey('duration_in_seconds')) { + context.handle( + _durationInSecondsMeta, + durationInSeconds.isAcceptableOrUnknown( + data['duration_in_seconds']!, _durationInSecondsMeta)); + } + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('checksum')) { + context.handle(_checksumMeta, + checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta)); + } + if (data.containsKey('is_favorite')) { + context.handle( + _isFavoriteMeta, + isFavorite.isAcceptableOrUnknown( + data['is_favorite']!, _isFavoriteMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + i1.LocalAssetEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.LocalAssetEntityData( + name: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}name'])!, + type: i1.$LocalAssetEntityTable.$convertertype.fromSql(attachedDatabase + .typeMapping + .read(i0.DriftSqlType.int, data['${effectivePrefix}type'])!), + createdAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + durationInSeconds: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, data['${effectivePrefix}duration_in_seconds']), + id: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}id'])!, + checksum: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}checksum']), + isFavorite: attachedDatabase.typeMapping + .read(i0.DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + ); + } + + @override + $LocalAssetEntityTable createAlias(String alias) { + return $LocalAssetEntityTable(attachedDatabase, alias); + } + + static i0.JsonTypeConverter2 $convertertype = + const i0.EnumIndexConverter(i2.AssetType.values); + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAssetEntityData extends i0.DataClass + implements i0.Insertable { + final String name; + final i2.AssetType type; + final DateTime createdAt; + final DateTime updatedAt; + final int? durationInSeconds; + final String id; + final String? checksum; + final bool isFavorite; + const LocalAssetEntityData( + {required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.durationInSeconds, + required this.id, + this.checksum, + required this.isFavorite}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = i0.Variable(name); + { + map['type'] = i0.Variable( + i1.$LocalAssetEntityTable.$convertertype.toSql(type)); + } + map['created_at'] = i0.Variable(createdAt); + map['updated_at'] = i0.Variable(updatedAt); + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = i0.Variable(durationInSeconds); + } + map['id'] = i0.Variable(id); + if (!nullToAbsent || checksum != null) { + map['checksum'] = i0.Variable(checksum); + } + map['is_favorite'] = i0.Variable(isFavorite); + return map; + } + + factory LocalAssetEntityData.fromJson(Map json, + {i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return LocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: i1.$LocalAssetEntityTable.$convertertype + .fromJson(serializer.fromJson(json['type'])), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer + .toJson(i1.$LocalAssetEntityTable.$convertertype.toJson(type)), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + }; + } + + i1.LocalAssetEntityData copyWith( + {String? name, + i2.AssetType? type, + DateTime? createdAt, + DateTime? updatedAt, + i0.Value durationInSeconds = const i0.Value.absent(), + String? id, + i0.Value checksum = const i0.Value.absent(), + bool? isFavorite}) => + i1.LocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ); + LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) { + return LocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, type, createdAt, updatedAt, + durationInSeconds, id, checksum, isFavorite); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.LocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite); +} + +class LocalAssetEntityCompanion + extends i0.UpdateCompanion { + final i0.Value name; + final i0.Value type; + final i0.Value createdAt; + final i0.Value updatedAt; + final i0.Value durationInSeconds; + final i0.Value id; + final i0.Value checksum; + final i0.Value isFavorite; + const LocalAssetEntityCompanion({ + this.name = const i0.Value.absent(), + this.type = const i0.Value.absent(), + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + this.durationInSeconds = const i0.Value.absent(), + this.id = const i0.Value.absent(), + this.checksum = const i0.Value.absent(), + this.isFavorite = const i0.Value.absent(), + }); + LocalAssetEntityCompanion.insert({ + required String name, + required i2.AssetType type, + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + this.durationInSeconds = const i0.Value.absent(), + required String id, + this.checksum = const i0.Value.absent(), + this.isFavorite = const i0.Value.absent(), + }) : name = i0.Value(name), + type = i0.Value(type), + id = i0.Value(id); + static i0.Insertable custom({ + i0.Expression? name, + i0.Expression? type, + i0.Expression? createdAt, + i0.Expression? updatedAt, + i0.Expression? durationInSeconds, + i0.Expression? id, + i0.Expression? checksum, + i0.Expression? isFavorite, + }) { + return i0.RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + }); + } + + i1.LocalAssetEntityCompanion copyWith( + {i0.Value? name, + i0.Value? type, + i0.Value? createdAt, + i0.Value? updatedAt, + i0.Value? durationInSeconds, + i0.Value? id, + i0.Value? checksum, + i0.Value? isFavorite}) { + return i1.LocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = i0.Variable(name.value); + } + if (type.present) { + map['type'] = i0.Variable( + i1.$LocalAssetEntityTable.$convertertype.toSql(type.value)); + } + if (createdAt.present) { + map['created_at'] = i0.Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = i0.Variable(updatedAt.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = i0.Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (checksum.present) { + map['checksum'] = i0.Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = i0.Variable(isFavorite.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 997714e1b6b..17fcad76bf5 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; @@ -25,7 +28,16 @@ class IsarDatabaseRepository implements IDatabaseRepository { Zone.current[_kzoneTxn] == null ? _db.writeTxn(callback) : callback(); } -@DriftDatabase(tables: [UserEntity, UserMetadataEntity, PartnerEntity]) +@DriftDatabase( + tables: [ + UserEntity, + UserMetadataEntity, + PartnerEntity, + LocalAlbumEntity, + LocalAssetEntity, + LocalAlbumAssetEntity, + ], +) class Drift extends $Drift implements IDatabaseRepository { Drift([QueryExecutor? executor]) : super( @@ -42,8 +54,9 @@ class Drift extends $Drift implements IDatabaseRepository { @override MigrationStrategy get migration => MigrationStrategy( beforeOpen: (details) async { - await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA foreign_keys = ON'); + await customStatement('PRAGMA synchronous = NORMAL'); + await customStatement('PRAGMA journal_mode = WAL'); }, ); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index a4c2b31dcd3..6611eb5c929 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -7,6 +7,12 @@ 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_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; abstract class $Drift extends i0.GeneratedDatabase { $Drift(i0.QueryExecutor e) : super(e); @@ -16,12 +22,25 @@ abstract class $Drift extends i0.GeneratedDatabase { i2.$UserMetadataEntityTable(this); late final i3.$PartnerEntityTable partnerEntity = i3.$PartnerEntityTable(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); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => - [userEntity, userMetadataEntity, partnerEntity]; + List get allSchemaEntities => [ + userEntity, + userMetadataEntity, + partnerEntity, + localAlbumEntity, + localAssetEntity, + localAlbumAssetEntity, + i5.localAssetChecksum + ]; @override i0.StreamQueryUpdateRules get streamUpdateRules => const i0.StreamQueryUpdateRules( @@ -48,6 +67,22 @@ 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_asset_entity', + kind: i0.UpdateKind.delete), + ], + ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName('local_album_entity', + limitUpdateKind: i0.UpdateKind.delete), + result: [ + i0.TableUpdate('local_album_asset_entity', + kind: i0.UpdateKind.delete), + ], + ), ], ); @override @@ -64,4 +99,10 @@ class $DriftManager { i2.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); i3.$$PartnerEntityTableTableManager get partnerEntity => i3.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); + 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); } diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart new file mode 100644 index 00000000000..650b7a1aab1 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -0,0 +1,366 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/interfaces/local_album.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/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:platform/platform.dart'; + +class DriftLocalAlbumRepository extends DriftDatabaseRepository + implements ILocalAlbumRepository { + final Drift _db; + final Platform _platform; + const DriftLocalAlbumRepository(this._db, {Platform? platform}) + : _platform = platform ?? const LocalPlatform(), + super(_db); + + @override + Future> getAll({SortLocalAlbumsBy? sortBy}) { + final assetCount = _db.localAlbumAssetEntity.assetId.count(); + + final query = _db.localAlbumEntity.select().join([ + leftOuterJoin( + _db.localAlbumAssetEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + ]); + query + ..addColumns([assetCount]) + ..groupBy([_db.localAlbumEntity.id]); + if (sortBy == SortLocalAlbumsBy.id) { + query.orderBy([OrderingTerm.asc(_db.localAlbumEntity.id)]); + } + return query + .map( + (row) => row + .readTable(_db.localAlbumEntity) + .toDto(assetCount: row.read(assetCount) ?? 0), + ) + .get(); + } + + @override + Future delete(String albumId) => transaction(() async { + // Remove all assets that are only in this particular album + // We cannot remove all assets in the album because they might be in other albums in iOS + // That is not the case on Android since asset <-> album has one:one mapping + final assetsToDelete = _platform.isIOS + ? await _getUniqueAssetsInAlbum(albumId) + : await getAssetIdsForAlbum(albumId); + await _deleteAssets(assetsToDelete); + + // All the other assets that are still associated will be unlinked automatically on-cascade + await _db.managers.localAlbumEntity + .filter((a) => a.id.equals(albumId)) + .delete(); + }); + + @override + Future syncAlbumDeletes( + String albumId, + Iterable assetIdsToKeep, + ) async { + if (assetIdsToKeep.isEmpty) { + return Future.value(); + } + + final deleteSmt = _db.localAssetEntity.delete(); + deleteSmt.where((localAsset) { + final subQuery = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId + .equalsExp(_db.localAlbumEntity.id), + ), + ]); + subQuery.where( + _db.localAlbumEntity.id.equals(albumId) & + _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep), + ); + return localAsset.id.isInQuery(subQuery); + }); + await deleteSmt.go(); + } + + @override + Future upsert( + LocalAlbum localAlbum, { + Iterable toUpsert = const [], + Iterable toDelete = const [], + }) { + final companion = LocalAlbumEntityCompanion.insert( + id: localAlbum.id, + name: localAlbum.name, + updatedAt: Value(localAlbum.updatedAt), + backupSelection: localAlbum.backupSelection, + ); + + return _db.transaction(() async { + await _db.localAlbumEntity + .insertOne(companion, onConflict: DoUpdate((_) => companion)); + await _addAssets(localAlbum.id, toUpsert); + await _removeAssets(localAlbum.id, toDelete); + }); + } + + @override + Future updateAll(Iterable albums) { + return _db.transaction(() async { + await _db.localAlbumEntity + .update() + .write(const LocalAlbumEntityCompanion(marker_: Value(true))); + + 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(null), + ); + + batch.insert( + _db.localAlbumEntity, + companion, + onConflict: DoUpdate((_) => companion), + ); + } + }); + + if (_platform.isAndroid) { + // On Android, an asset can only be in one album + // So, get the albums that are marked for deletion + // and delete all the assets that are in those albums + final deleteSmt = _db.localAssetEntity.delete(); + deleteSmt.where((localAsset) { + final subQuery = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId + .equalsExp(_db.localAlbumEntity.id), + ), + ]); + subQuery.where(_db.localAlbumEntity.marker_.isNotNull()); + return localAsset.id.isInQuery(subQuery); + }); + await deleteSmt.go(); + } + + await _db.localAlbumEntity.deleteWhere((f) => f.marker_.isNotNull()); + }); + } + + @override + Future> getAssetsForAlbum(String albumId) { + final query = _db.localAlbumAssetEntity.select().join( + [ + innerJoin( + _db.localAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + ), + ], + ) + ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) + ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]); + return query + .map((row) => row.readTable(_db.localAssetEntity).toDto()) + .get(); + } + + @override + Future> getAssetIdsForAlbum(String albumId) { + final query = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)); + return query + .map((row) => row.read(_db.localAlbumAssetEntity.assetId)!) + .get(); + } + + @override + Future processDelta({ + required List updates, + required List deletes, + required Map> assetAlbums, + }) { + return _db.transaction(() async { + await _deleteAssets(deletes); + + await _upsertAssets(updates); + // The ugly casting below is required for now because the generated code + // casts the returned values from the platform during decoding them + // and iterating over them causes the type to be List instead of + // List + await _db.batch((batch) async { + assetAlbums.cast>().forEach((assetId, albumIds) { + batch.deleteWhere( + _db.localAlbumAssetEntity, + (f) => + f.albumId.isNotIn(albumIds.cast().nonNulls) & + f.assetId.equals(assetId), + ); + }); + }); + await _db.batch((batch) async { + assetAlbums.cast>().forEach((assetId, albumIds) { + batch.insertAll( + _db.localAlbumAssetEntity, + albumIds.cast().nonNulls.map( + (albumId) => LocalAlbumAssetEntityCompanion.insert( + assetId: assetId, + albumId: albumId, + ), + ), + onConflict: DoNothing(), + ); + }); + }); + }); + } + + Future _addAssets(String albumId, Iterable assets) { + if (assets.isEmpty) { + return Future.value(); + } + return transaction(() async { + await _upsertAssets(assets); + await _db.localAlbumAssetEntity.insertAll( + assets.map( + (a) => LocalAlbumAssetEntityCompanion.insert( + assetId: a.id, + albumId: albumId, + ), + ), + mode: InsertMode.insertOrIgnore, + ); + }); + } + + Future _removeAssets(String albumId, Iterable assetIds) async { + if (assetIds.isEmpty) { + return Future.value(); + } + + if (_platform.isAndroid) { + return _deleteAssets(assetIds); + } + + List assetsToDelete = []; + List assetsToUnLink = []; + + final uniqueAssets = await _getUniqueAssetsInAlbum(albumId); + if (uniqueAssets.isEmpty) { + assetsToUnLink = assetIds.toList(); + } else { + // Delete unique assets and unlink others + final uniqueSet = uniqueAssets.toSet(); + + for (final assetId in assetIds) { + if (uniqueSet.contains(assetId)) { + assetsToDelete.add(assetId); + } else { + assetsToUnLink.add(assetId); + } + } + } + + return transaction(() async { + if (assetsToUnLink.isNotEmpty) { + await _db.batch( + (batch) => batch.deleteWhere( + _db.localAlbumAssetEntity, + (f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId), + ), + ); + } + + await _deleteAssets(assetsToDelete); + }); + } + + /// Get all asset ids that are only in this album and not in other albums. + /// This is useful in cases where the album is a smart album or a user-created album, especially on iOS + Future> _getUniqueAssetsInAlbum(String albumId) { + final assetId = _db.localAlbumAssetEntity.assetId; + final query = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([assetId]) + ..groupBy( + [assetId], + having: _db.localAlbumAssetEntity.albumId.count().equals(1) & + _db.localAlbumAssetEntity.albumId.equals(albumId), + ); + + return query.map((row) => row.read(assetId)!).get(); + } + + Future _upsertAssets(Iterable localAssets) { + if (localAssets.isEmpty) { + return Future.value(); + } + + return _db.batch((batch) async { + batch.insertAllOnConflictUpdate( + _db.localAssetEntity, + localAssets.map( + (a) => LocalAssetEntityCompanion.insert( + name: a.name, + type: a.type, + createdAt: Value(a.createdAt), + updatedAt: Value(a.updatedAt), + durationInSeconds: Value.absentIfNull(a.durationInSeconds), + id: a.id, + checksum: Value.absentIfNull(a.checksum), + ), + ), + ); + }); + } + + Future _deleteAssets(Iterable ids) { + if (ids.isEmpty) { + return Future.value(); + } + + return _db.batch( + (batch) => batch.deleteWhere( + _db.localAssetEntity, + (f) => f.id.isIn(ids), + ), + ); + } +} + +extension on LocalAlbumEntityData { + LocalAlbum toDto({int assetCount = 0}) { + return LocalAlbum( + id: id, + name: name, + updatedAt: updatedAt, + assetCount: assetCount, + backupSelection: backupSelection, + ); + } +} + +extension on LocalAssetEntityData { + LocalAsset toDto() { + return LocalAsset( + id: id, + name: name, + checksum: checksum, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds, + isFavorite: isFavorite, + ); + } +} diff --git a/mobile/lib/infrastructure/utils/asset.mixin.dart b/mobile/lib/infrastructure/utils/asset.mixin.dart new file mode 100644 index 00000000000..86495508268 --- /dev/null +++ b/mobile/lib/infrastructure/utils/asset.mixin.dart @@ -0,0 +1,10 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +mixin AssetEntityMixin on Table { + TextColumn get name => text()(); + IntColumn get type => intEnum()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + IntColumn get durationInSeconds => integer().nullable()(); +} diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart new file mode 100644 index 00000000000..c4e4c467d41 --- /dev/null +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -0,0 +1,501 @@ +// Autogenerated from Pigeon (v25.3.2), 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 entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + +class PlatformAsset { + PlatformAsset({ + required this.id, + required this.name, + required this.type, + this.createdAt, + this.updatedAt, + required this.durationInSeconds, + }); + + String id; + + String name; + + int type; + + int? createdAt; + + int? updatedAt; + + int durationInSeconds; + + List _toList() { + return [ + id, + name, + type, + createdAt, + updatedAt, + durationInSeconds, + ]; + } + + Object encode() { + return _toList(); + } + + static PlatformAsset decode(Object result) { + result as List; + return PlatformAsset( + id: result[0]! as String, + name: result[1]! as String, + type: result[2]! as int, + createdAt: result[3] as int?, + updatedAt: result[4] as int?, + durationInSeconds: result[5]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlatformAsset || 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 PlatformAlbum { + PlatformAlbum({ + required this.id, + required this.name, + this.updatedAt, + required this.isCloud, + required this.assetCount, + }); + + String id; + + String name; + + int? updatedAt; + + bool isCloud; + + int assetCount; + + List _toList() { + return [ + id, + name, + updatedAt, + isCloud, + assetCount, + ]; + } + + Object encode() { + return _toList(); + } + + static PlatformAlbum decode(Object result) { + result as List; + return PlatformAlbum( + id: result[0]! as String, + name: result[1]! as String, + updatedAt: result[2] as int?, + isCloud: result[3]! as bool, + assetCount: result[4]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlatformAlbum || 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({ + required this.hasChanges, + required this.updates, + required this.deletes, + required this.assetAlbums, + }); + + bool hasChanges; + + List updates; + + List deletes; + + Map> assetAlbums; + + List _toList() { + return [ + hasChanges, + updates, + deletes, + assetAlbums, + ]; + } + + Object encode() { + return _toList(); + } + + static SyncDelta decode(Object result) { + result as List; + return SyncDelta( + hasChanges: result[0]! as bool, + updates: (result[1] as List?)!.cast(), + deletes: (result[2] as List?)!.cast(), + assetAlbums: + (result[3] as Map?)!.cast>(), + ); + } + + @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 PlatformAsset) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is PlatformAlbum) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is SyncDelta) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return PlatformAsset.decode(readValue(buffer)!); + case 130: + return PlatformAlbum.decode(readValue(buffer)!); + case 131: + return SyncDelta.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class NativeSyncApi { + /// Constructor for [NativeSyncApi]. 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. + NativeSyncApi( + {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future shouldFullSync() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + 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 getMediaChanges() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + 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 checkpointSync() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + 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; + } + } + + Future clearSyncCheckpoint() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + 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; + } + } + + Future> getAssetIdsForAlbum(String albumId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([albumId]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + 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 List?)!.cast(); + } + } + + Future> getAlbums() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + 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 List?)!.cast(); + } + } + + Future getAssetsCountSince(String albumId, int timestamp) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([albumId, timestamp]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + 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 int?)!; + } + } + + Future> getAssetsForAlbum(String albumId, + {int? updatedTimeCond}) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([albumId, updatedTimeCond]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + 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 List?)!.cast(); + } + } +} diff --git a/mobile/lib/presentation/pages/dev/dev_logger.dart b/mobile/lib/presentation/pages/dev/dev_logger.dart new file mode 100644 index 00000000000..6d179241a48 --- /dev/null +++ b/mobile/lib/presentation/pages/dev/dev_logger.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; +// ignore: import_rule_isar +import 'package:isar/isar.dart'; + +const kDevLoggerTag = 'DEV'; + +abstract final class DLog { + const DLog(); + + static Stream> watchLog() { + final db = Isar.getInstance(); + if (db == null) { + debugPrint('Isar is not initialized'); + return const Stream.empty(); + } + + return db.loggerMessages + .filter() + .context1EqualTo(kDevLoggerTag) + .sortByCreatedAtDesc() + .watch(fireImmediately: true) + .map((logs) => logs.map((log) => log.toDto()).toList()); + } + + static void clearLog() { + final db = Isar.getInstance(); + if (db == null) { + debugPrint('Isar is not initialized'); + return; + } + + db.writeTxnSync(() { + db.loggerMessages.filter().context1EqualTo(kDevLoggerTag).deleteAllSync(); + }); + } + + static void log(String message, [Object? error, StackTrace? stackTrace]) { + debugPrint('[$kDevLoggerTag] [${DateTime.now()}] $message'); + if (error != null) { + debugPrint('Error: $error'); + } + if (stackTrace != null) { + debugPrint('StackTrace: $stackTrace'); + } + + final isar = Isar.getInstance(); + if (isar == null) { + debugPrint('Isar is not initialized'); + return; + } + + final record = LogMessage( + message: message, + level: LogLevel.info, + createdAt: DateTime.now(), + logger: kDevLoggerTag, + error: error?.toString(), + stack: stackTrace?.toString(), + ); + + unawaited(IsarLogRepository(isar).insert(record)); + } +} diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart new file mode 100644 index 00000000000..da0bea157fe --- /dev/null +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -0,0 +1,174 @@ +// ignore_for_file: avoid-local-functions + +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:drift/drift.dart' hide Column; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +final _features = [ + _Feature( + name: 'Sync Local', + icon: Icons.photo_album_rounded, + onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(), + ), + _Feature( + name: 'Sync Local Full', + icon: Icons.photo_library_rounded, + onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true), + ), + _Feature( + name: 'Sync Remote', + icon: Icons.refresh_rounded, + onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(), + ), + _Feature( + name: 'WAL Checkpoint', + icon: Icons.save_rounded, + onTap: (_, ref) => ref + .read(driftProvider) + .customStatement("pragma wal_checkpoint(truncate)"), + ), + _Feature( + name: 'Clear Delta Checkpoint', + icon: Icons.delete_rounded, + onTap: (_, ref) => ref.read(nativeSyncApiProvider).clearSyncCheckpoint(), + ), + _Feature( + name: 'Clear Local Data', + icon: Icons.delete_forever_rounded, + onTap: (_, ref) async { + final db = ref.read(driftProvider); + await db.localAssetEntity.deleteAll(); + await db.localAlbumEntity.deleteAll(); + await db.localAlbumAssetEntity.deleteAll(); + }, + ), + _Feature( + name: 'Local Media Summary', + icon: Icons.table_chart_rounded, + onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()), + ), +]; + +@RoutePage() +class FeatInDevPage extends StatelessWidget { + const FeatInDevPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Features in Development'), + centerTitle: true, + ), + body: Column( + children: [ + Flexible( + flex: 1, + child: ListView.builder( + itemBuilder: (_, index) { + final feat = _features[index]; + return Consumer( + builder: (ctx, ref, _) => ListTile( + title: Text(feat.name), + trailing: Icon(feat.icon), + visualDensity: VisualDensity.compact, + onTap: () => unawaited(feat.onTap(ctx, ref)), + ), + ); + }, + itemCount: _features.length, + ), + ), + const Divider(height: 0), + const Flexible(child: _DevLogs()), + ], + ), + ); + } +} + +class _Feature { + const _Feature({ + required this.name, + required this.icon, + required this.onTap, + }); + + final String name; + final IconData icon; + final Future Function(BuildContext, WidgetRef _) onTap; +} + +// ignore: prefer-single-widget-per-file +class _DevLogs extends StatelessWidget { + const _DevLogs(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + actions: [ + IconButton( + onPressed: DLog.clearLog, + icon: Icon( + Icons.delete_outline_rounded, + size: 20.0, + color: context.primaryColor, + semanticLabel: "Clear logs", + ), + ), + ], + centerTitle: true, + ), + body: StreamBuilder( + initialData: [], + stream: DLog.watchLog(), + builder: (_, logMessages) { + return ListView.separated( + itemBuilder: (ctx, index) { + // ignore: avoid-unsafe-collection-methods + final logMessage = logMessages.data![index]; + return ListTile( + title: Text( + logMessage.message, + style: TextStyle( + color: ctx.colorScheme.onSurface, + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + ), + ), + subtitle: Text( + "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}", + style: TextStyle( + color: ctx.colorScheme.onSurfaceSecondary, + fontSize: 12.0, + ), + ), + dense: true, + visualDensity: VisualDensity.compact, + tileColor: Colors.transparent, + minLeadingWidth: 10, + ); + }, + separatorBuilder: (_, index) { + return const Divider(height: 0); + }, + itemCount: logMessages.data?.length ?? 0, + ); + }, + ), + ); + } +} diff --git a/mobile/lib/presentation/pages/dev/local_media_stat.page.dart b/mobile/lib/presentation/pages/dev/local_media_stat.page.dart new file mode 100644 index 00000000000..b42cae84fed --- /dev/null +++ b/mobile/lib/presentation/pages/dev/local_media_stat.page.dart @@ -0,0 +1,125 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/local_album.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; + +final _stats = [ + _Stat( + name: 'Local Assets', + load: (db) => db.managers.localAssetEntity.count(), + ), + _Stat( + name: 'Local Albums', + load: (db) => db.managers.localAlbumEntity.count(), + ), +]; + +@RoutePage() +class LocalMediaSummaryPage extends StatelessWidget { + const LocalMediaSummaryPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Local Media Summary')), + body: Consumer( + builder: (ctx, ref, __) { + final db = ref.watch(driftProvider); + final albumsFuture = ref.watch(localAlbumRepository).getAll(); + + return CustomScrollView( + slivers: [ + SliverList.builder( + itemBuilder: (_, index) { + final stat = _stats[index]; + final countFuture = stat.load(db); + return _Summary(name: stat.name, countFuture: countFuture); + }, + itemCount: _stats.length, + ), + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Divider(), + Padding( + padding: const EdgeInsets.only(left: 15), + child: Text( + "Album summary", + style: ctx.textTheme.titleMedium, + ), + ), + ], + ), + ), + FutureBuilder( + future: albumsFuture, + initialData: [], + builder: (_, snap) { + final albums = snap.data!; + if (albums.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + albums.sortBy((a) => a.name); + return SliverList.builder( + itemBuilder: (_, index) { + final album = albums[index]; + final countFuture = db.managers.localAlbumAssetEntity + .filter((f) => f.albumId.id.equals(album.id)) + .count(); + return _Summary( + name: album.name, + countFuture: countFuture, + ); + }, + itemCount: albums.length, + ); + }, + ), + ], + ); + }, + ), + ); + } +} + +// ignore: prefer-single-widget-per-file +class _Summary extends StatelessWidget { + final String name; + final Future countFuture; + + const _Summary({required this.name, required this.countFuture}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: countFuture, + builder: (ctx, snapshot) { + final Widget subtitle; + + if (snapshot.connectionState == ConnectionState.waiting) { + subtitle = const CircularProgressIndicator(); + } else if (snapshot.hasError) { + subtitle = const Icon(Icons.error_rounded); + } else { + subtitle = Text('${snapshot.data ?? 0}'); + } + return ListTile(title: Text(name), trailing: subtitle); + }, + ); + } +} + +class _Stat { + const _Stat({required this.name, required this.load}); + + final String name; + final Future Function(Drift _) load; +} diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart new file mode 100644 index 00000000000..cb4aadb8a75 --- /dev/null +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -0,0 +1,8 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; + +final localAlbumRepository = Provider( + (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), +); diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart new file mode 100644 index 00000000000..477046d0bf3 --- /dev/null +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -0,0 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; + +final nativeSyncApiProvider = Provider((_) => NativeSyncApi()); diff --git a/mobile/lib/providers/infrastructure/sync_stream.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart similarity index 64% rename from mobile/lib/providers/infrastructure/sync_stream.provider.dart rename to mobile/lib/providers/infrastructure/sync.provider.dart index e313982a301..96e470eba26 100644 --- a/mobile/lib/providers/infrastructure/sync_stream.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -1,10 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/local_sync.service.dart'; import 'package:immich_mobile/domain/services/sync_stream.service.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; 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/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; final syncStreamServiceProvider = Provider( (ref) => SyncStreamService( @@ -21,3 +25,11 @@ final syncApiRepositoryProvider = Provider( final syncStreamRepositoryProvider = Provider( (ref) => DriftSyncStreamRepository(ref.watch(driftProvider)), ); + +final localSyncServiceProvider = Provider( + (ref) => LocalSyncService( + localAlbumRepository: ref.watch(localAlbumRepository), + nativeSyncApi: ref.watch(nativeSyncApiProvider), + storeService: ref.watch(storeServiceProvider), + ), +); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 317ce7cc542..a6e1d89ff38 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -63,6 +63,8 @@ import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/local_media_stat.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; @@ -316,5 +318,13 @@ class AppRouter extends RootStackRouter { page: PinAuthRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: FeatInDevRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + AutoRoute( + page: LocalMediaSummaryRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index da488779e6f..57fb8cef809 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1,3 +1,4 @@ +// dart format width=80 // GENERATED CODE - DO NOT MODIFY BY HAND // ************************************************************************** @@ -13,10 +14,7 @@ part of 'router.dart'; /// [ActivitiesPage] class ActivitiesRoute extends PageRouteInfo { const ActivitiesRoute({List? children}) - : super( - ActivitiesRoute.name, - initialChildren: children, - ); + : super(ActivitiesRoute.name, initialChildren: children); static const String name = 'ActivitiesRoute'; @@ -132,10 +130,7 @@ class AlbumAssetSelectionRouteArgs { /// [AlbumOptionsPage] class AlbumOptionsRoute extends PageRouteInfo { const AlbumOptionsRoute({List? children}) - : super( - AlbumOptionsRoute.name, - initialChildren: children, - ); + : super(AlbumOptionsRoute.name, initialChildren: children); static const String name = 'AlbumOptionsRoute'; @@ -156,10 +151,7 @@ class AlbumPreviewRoute extends PageRouteInfo { List? children, }) : super( AlbumPreviewRoute.name, - args: AlbumPreviewRouteArgs( - key: key, - album: album, - ), + args: AlbumPreviewRouteArgs(key: key, album: album), initialChildren: children, ); @@ -169,19 +161,13 @@ class AlbumPreviewRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return AlbumPreviewPage( - key: args.key, - album: args.album, - ); + return AlbumPreviewPage(key: args.key, album: args.album); }, ); } class AlbumPreviewRouteArgs { - const AlbumPreviewRouteArgs({ - this.key, - required this.album, - }); + const AlbumPreviewRouteArgs({this.key, required this.album}); final Key? key; @@ -203,10 +189,7 @@ class AlbumSharedUserSelectionRoute List? children, }) : super( AlbumSharedUserSelectionRoute.name, - args: AlbumSharedUserSelectionRouteArgs( - key: key, - assets: assets, - ), + args: AlbumSharedUserSelectionRouteArgs(key: key, assets: assets), initialChildren: children, ); @@ -216,19 +199,13 @@ class AlbumSharedUserSelectionRoute name, builder: (data) { final args = data.argsAs(); - return AlbumSharedUserSelectionPage( - key: args.key, - assets: args.assets, - ); + return AlbumSharedUserSelectionPage(key: args.key, assets: args.assets); }, ); } class AlbumSharedUserSelectionRouteArgs { - const AlbumSharedUserSelectionRouteArgs({ - this.key, - required this.assets, - }); + const AlbumSharedUserSelectionRouteArgs({this.key, required this.assets}); final Key? key; @@ -249,10 +226,7 @@ class AlbumViewerRoute extends PageRouteInfo { List? children, }) : super( AlbumViewerRoute.name, - args: AlbumViewerRouteArgs( - key: key, - albumId: albumId, - ), + args: AlbumViewerRouteArgs(key: key, albumId: albumId), initialChildren: children, ); @@ -262,19 +236,13 @@ class AlbumViewerRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return AlbumViewerPage( - key: args.key, - albumId: args.albumId, - ); + return AlbumViewerPage(key: args.key, albumId: args.albumId); }, ); } class AlbumViewerRouteArgs { - const AlbumViewerRouteArgs({ - this.key, - required this.albumId, - }); + const AlbumViewerRouteArgs({this.key, required this.albumId}); final Key? key; @@ -290,10 +258,7 @@ class AlbumViewerRouteArgs { /// [AlbumsPage] class AlbumsRoute extends PageRouteInfo { const AlbumsRoute({List? children}) - : super( - AlbumsRoute.name, - initialChildren: children, - ); + : super(AlbumsRoute.name, initialChildren: children); static const String name = 'AlbumsRoute'; @@ -309,10 +274,7 @@ class AlbumsRoute extends PageRouteInfo { /// [AllMotionPhotosPage] class AllMotionPhotosRoute extends PageRouteInfo { const AllMotionPhotosRoute({List? children}) - : super( - AllMotionPhotosRoute.name, - initialChildren: children, - ); + : super(AllMotionPhotosRoute.name, initialChildren: children); static const String name = 'AllMotionPhotosRoute'; @@ -328,10 +290,7 @@ class AllMotionPhotosRoute extends PageRouteInfo { /// [AllPeoplePage] class AllPeopleRoute extends PageRouteInfo { const AllPeopleRoute({List? children}) - : super( - AllPeopleRoute.name, - initialChildren: children, - ); + : super(AllPeopleRoute.name, initialChildren: children); static const String name = 'AllPeopleRoute'; @@ -347,10 +306,7 @@ class AllPeopleRoute extends PageRouteInfo { /// [AllPlacesPage] class AllPlacesRoute extends PageRouteInfo { const AllPlacesRoute({List? children}) - : super( - AllPlacesRoute.name, - initialChildren: children, - ); + : super(AllPlacesRoute.name, initialChildren: children); static const String name = 'AllPlacesRoute'; @@ -366,10 +322,7 @@ class AllPlacesRoute extends PageRouteInfo { /// [AllVideosPage] class AllVideosRoute extends PageRouteInfo { const AllVideosRoute({List? children}) - : super( - AllVideosRoute.name, - initialChildren: children, - ); + : super(AllVideosRoute.name, initialChildren: children); static const String name = 'AllVideosRoute'; @@ -390,10 +343,7 @@ class AppLogDetailRoute extends PageRouteInfo { List? children, }) : super( AppLogDetailRoute.name, - args: AppLogDetailRouteArgs( - key: key, - logMessage: logMessage, - ), + args: AppLogDetailRouteArgs(key: key, logMessage: logMessage), initialChildren: children, ); @@ -403,19 +353,13 @@ class AppLogDetailRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return AppLogDetailPage( - key: args.key, - logMessage: args.logMessage, - ); + return AppLogDetailPage(key: args.key, logMessage: args.logMessage); }, ); } class AppLogDetailRouteArgs { - const AppLogDetailRouteArgs({ - this.key, - required this.logMessage, - }); + const AppLogDetailRouteArgs({this.key, required this.logMessage}); final Key? key; @@ -431,10 +375,7 @@ class AppLogDetailRouteArgs { /// [AppLogPage] class AppLogRoute extends PageRouteInfo { const AppLogRoute({List? children}) - : super( - AppLogRoute.name, - initialChildren: children, - ); + : super(AppLogRoute.name, initialChildren: children); static const String name = 'AppLogRoute'; @@ -450,10 +391,7 @@ class AppLogRoute extends PageRouteInfo { /// [ArchivePage] class ArchiveRoute extends PageRouteInfo { const ArchiveRoute({List? children}) - : super( - ArchiveRoute.name, - initialChildren: children, - ); + : super(ArchiveRoute.name, initialChildren: children); static const String name = 'ArchiveRoute'; @@ -469,10 +407,7 @@ class ArchiveRoute extends PageRouteInfo { /// [BackupAlbumSelectionPage] class BackupAlbumSelectionRoute extends PageRouteInfo { const BackupAlbumSelectionRoute({List? children}) - : super( - BackupAlbumSelectionRoute.name, - initialChildren: children, - ); + : super(BackupAlbumSelectionRoute.name, initialChildren: children); static const String name = 'BackupAlbumSelectionRoute'; @@ -488,10 +423,7 @@ class BackupAlbumSelectionRoute extends PageRouteInfo { /// [BackupControllerPage] class BackupControllerRoute extends PageRouteInfo { const BackupControllerRoute({List? children}) - : super( - BackupControllerRoute.name, - initialChildren: children, - ); + : super(BackupControllerRoute.name, initialChildren: children); static const String name = 'BackupControllerRoute'; @@ -507,10 +439,7 @@ class BackupControllerRoute extends PageRouteInfo { /// [BackupOptionsPage] class BackupOptionsRoute extends PageRouteInfo { const BackupOptionsRoute({List? children}) - : super( - BackupOptionsRoute.name, - initialChildren: children, - ); + : super(BackupOptionsRoute.name, initialChildren: children); static const String name = 'BackupOptionsRoute'; @@ -526,10 +455,7 @@ class BackupOptionsRoute extends PageRouteInfo { /// [ChangePasswordPage] class ChangePasswordRoute extends PageRouteInfo { const ChangePasswordRoute({List? children}) - : super( - ChangePasswordRoute.name, - initialChildren: children, - ); + : super(ChangePasswordRoute.name, initialChildren: children); static const String name = 'ChangePasswordRoute'; @@ -550,10 +476,7 @@ class CreateAlbumRoute extends PageRouteInfo { List? children, }) : super( CreateAlbumRoute.name, - args: CreateAlbumRouteArgs( - key: key, - assets: assets, - ), + args: CreateAlbumRouteArgs(key: key, assets: assets), initialChildren: children, ); @@ -563,20 +486,15 @@ class CreateAlbumRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs( - orElse: () => const CreateAlbumRouteArgs()); - return CreateAlbumPage( - key: args.key, - assets: args.assets, + orElse: () => const CreateAlbumRouteArgs(), ); + return CreateAlbumPage(key: args.key, assets: args.assets); }, ); } class CreateAlbumRouteArgs { - const CreateAlbumRouteArgs({ - this.key, - this.assets, - }); + const CreateAlbumRouteArgs({this.key, this.assets}); final Key? key; @@ -598,11 +516,7 @@ class CropImageRoute extends PageRouteInfo { List? children, }) : super( CropImageRoute.name, - args: CropImageRouteArgs( - key: key, - image: image, - asset: asset, - ), + args: CropImageRouteArgs(key: key, image: image, asset: asset), initialChildren: children, ); @@ -612,11 +526,7 @@ class CropImageRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return CropImagePage( - key: args.key, - image: args.image, - asset: args.asset, - ); + return CropImagePage(key: args.key, image: args.image, asset: args.asset); }, ); } @@ -702,10 +612,7 @@ class EditImageRouteArgs { /// [FailedBackupStatusPage] class FailedBackupStatusRoute extends PageRouteInfo { const FailedBackupStatusRoute({List? children}) - : super( - FailedBackupStatusRoute.name, - initialChildren: children, - ); + : super(FailedBackupStatusRoute.name, initialChildren: children); static const String name = 'FailedBackupStatusRoute'; @@ -721,10 +628,7 @@ class FailedBackupStatusRoute extends PageRouteInfo { /// [FavoritesPage] class FavoritesRoute extends PageRouteInfo { const FavoritesRoute({List? children}) - : super( - FavoritesRoute.name, - initialChildren: children, - ); + : super(FavoritesRoute.name, initialChildren: children); static const String name = 'FavoritesRoute'; @@ -736,6 +640,22 @@ class FavoritesRoute extends PageRouteInfo { ); } +/// generated route for +/// [FeatInDevPage] +class FeatInDevRoute extends PageRouteInfo { + const FeatInDevRoute({List? children}) + : super(FeatInDevRoute.name, initialChildren: children); + + static const String name = 'FeatInDevRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const FeatInDevPage(); + }, + ); +} + /// generated route for /// [FilterImagePage] class FilterImageRoute extends PageRouteInfo { @@ -746,11 +666,7 @@ class FilterImageRoute extends PageRouteInfo { List? children, }) : super( FilterImageRoute.name, - args: FilterImageRouteArgs( - key: key, - image: image, - asset: asset, - ), + args: FilterImageRouteArgs(key: key, image: image, asset: asset), initialChildren: children, ); @@ -797,10 +713,7 @@ class FolderRoute extends PageRouteInfo { List? children, }) : super( FolderRoute.name, - args: FolderRouteArgs( - key: key, - folder: folder, - ), + args: FolderRouteArgs(key: key, folder: folder), initialChildren: children, ); @@ -809,21 +722,16 @@ class FolderRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = - data.argsAs(orElse: () => const FolderRouteArgs()); - return FolderPage( - key: args.key, - folder: args.folder, + final args = data.argsAs( + orElse: () => const FolderRouteArgs(), ); + return FolderPage(key: args.key, folder: args.folder); }, ); } class FolderRouteArgs { - const FolderRouteArgs({ - this.key, - this.folder, - }); + const FolderRouteArgs({this.key, this.folder}); final Key? key; @@ -903,10 +811,7 @@ class GalleryViewerRouteArgs { /// [HeaderSettingsPage] class HeaderSettingsRoute extends PageRouteInfo { const HeaderSettingsRoute({List? children}) - : super( - HeaderSettingsRoute.name, - initialChildren: children, - ); + : super(HeaderSettingsRoute.name, initialChildren: children); static const String name = 'HeaderSettingsRoute'; @@ -922,10 +827,7 @@ class HeaderSettingsRoute extends PageRouteInfo { /// [LibraryPage] class LibraryRoute extends PageRouteInfo { const LibraryRoute({List? children}) - : super( - LibraryRoute.name, - initialChildren: children, - ); + : super(LibraryRoute.name, initialChildren: children); static const String name = 'LibraryRoute'; @@ -941,10 +843,7 @@ class LibraryRoute extends PageRouteInfo { /// [LocalAlbumsPage] class LocalAlbumsRoute extends PageRouteInfo { const LocalAlbumsRoute({List? children}) - : super( - LocalAlbumsRoute.name, - initialChildren: children, - ); + : super(LocalAlbumsRoute.name, initialChildren: children); static const String name = 'LocalAlbumsRoute'; @@ -956,14 +855,27 @@ class LocalAlbumsRoute extends PageRouteInfo { ); } +/// generated route for +/// [LocalMediaSummaryPage] +class LocalMediaSummaryRoute extends PageRouteInfo { + const LocalMediaSummaryRoute({List? children}) + : super(LocalMediaSummaryRoute.name, initialChildren: children); + + static const String name = 'LocalMediaSummaryRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LocalMediaSummaryPage(); + }, + ); +} + /// generated route for /// [LockedPage] class LockedRoute extends PageRouteInfo { const LockedRoute({List? children}) - : super( - LockedRoute.name, - initialChildren: children, - ); + : super(LockedRoute.name, initialChildren: children); static const String name = 'LockedRoute'; @@ -979,10 +891,7 @@ class LockedRoute extends PageRouteInfo { /// [LoginPage] class LoginRoute extends PageRouteInfo { const LoginRoute({List? children}) - : super( - LoginRoute.name, - initialChildren: children, - ); + : super(LoginRoute.name, initialChildren: children); static const String name = 'LoginRoute'; @@ -1016,7 +925,8 @@ class MapLocationPickerRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs( - orElse: () => const MapLocationPickerRouteArgs()); + orElse: () => const MapLocationPickerRouteArgs(), + ); return MapLocationPickerPage( key: args.key, initialLatLng: args.initialLatLng, @@ -1044,16 +954,10 @@ class MapLocationPickerRouteArgs { /// generated route for /// [MapPage] class MapRoute extends PageRouteInfo { - MapRoute({ - Key? key, - LatLng? initialLocation, - List? children, - }) : super( + MapRoute({Key? key, LatLng? initialLocation, List? children}) + : super( MapRoute.name, - args: MapRouteArgs( - key: key, - initialLocation: initialLocation, - ), + args: MapRouteArgs(key: key, initialLocation: initialLocation), initialChildren: children, ); @@ -1062,21 +966,16 @@ class MapRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = - data.argsAs(orElse: () => const MapRouteArgs()); - return MapPage( - key: args.key, - initialLocation: args.initialLocation, + final args = data.argsAs( + orElse: () => const MapRouteArgs(), ); + return MapPage(key: args.key, initialLocation: args.initialLocation); }, ); } class MapRouteArgs { - const MapRouteArgs({ - this.key, - this.initialLocation, - }); + const MapRouteArgs({this.key, this.initialLocation}); final Key? key; @@ -1213,10 +1112,7 @@ class PartnerDetailRoute extends PageRouteInfo { List? children, }) : super( PartnerDetailRoute.name, - args: PartnerDetailRouteArgs( - key: key, - partner: partner, - ), + args: PartnerDetailRouteArgs(key: key, partner: partner), initialChildren: children, ); @@ -1226,19 +1122,13 @@ class PartnerDetailRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return PartnerDetailPage( - key: args.key, - partner: args.partner, - ); + return PartnerDetailPage(key: args.key, partner: args.partner); }, ); } class PartnerDetailRouteArgs { - const PartnerDetailRouteArgs({ - this.key, - required this.partner, - }); + const PartnerDetailRouteArgs({this.key, required this.partner}); final Key? key; @@ -1254,10 +1144,7 @@ class PartnerDetailRouteArgs { /// [PartnerPage] class PartnerRoute extends PageRouteInfo { const PartnerRoute({List? children}) - : super( - PartnerRoute.name, - initialChildren: children, - ); + : super(PartnerRoute.name, initialChildren: children); static const String name = 'PartnerRoute'; @@ -1273,10 +1160,7 @@ class PartnerRoute extends PageRouteInfo { /// [PeopleCollectionPage] class PeopleCollectionRoute extends PageRouteInfo { const PeopleCollectionRoute({List? children}) - : super( - PeopleCollectionRoute.name, - initialChildren: children, - ); + : super(PeopleCollectionRoute.name, initialChildren: children); static const String name = 'PeopleCollectionRoute'; @@ -1292,10 +1176,7 @@ class PeopleCollectionRoute extends PageRouteInfo { /// [PermissionOnboardingPage] class PermissionOnboardingRoute extends PageRouteInfo { const PermissionOnboardingRoute({List? children}) - : super( - PermissionOnboardingRoute.name, - initialChildren: children, - ); + : super(PermissionOnboardingRoute.name, initialChildren: children); static const String name = 'PermissionOnboardingRoute'; @@ -1363,10 +1244,7 @@ class PersonResultRouteArgs { /// [PhotosPage] class PhotosRoute extends PageRouteInfo { const PhotosRoute({List? children}) - : super( - PhotosRoute.name, - initialChildren: children, - ); + : super(PhotosRoute.name, initialChildren: children); static const String name = 'PhotosRoute'; @@ -1387,10 +1265,7 @@ class PinAuthRoute extends PageRouteInfo { List? children, }) : super( PinAuthRoute.name, - args: PinAuthRouteArgs( - key: key, - createPinCode: createPinCode, - ), + args: PinAuthRouteArgs(key: key, createPinCode: createPinCode), initialChildren: children, ); @@ -1399,21 +1274,16 @@ class PinAuthRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = - data.argsAs(orElse: () => const PinAuthRouteArgs()); - return PinAuthPage( - key: args.key, - createPinCode: args.createPinCode, + final args = data.argsAs( + orElse: () => const PinAuthRouteArgs(), ); + return PinAuthPage(key: args.key, createPinCode: args.createPinCode); }, ); } class PinAuthRouteArgs { - const PinAuthRouteArgs({ - this.key, - this.createPinCode = false, - }); + const PinAuthRouteArgs({this.key, this.createPinCode = false}); final Key? key; @@ -1447,7 +1317,8 @@ class PlacesCollectionRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs( - orElse: () => const PlacesCollectionRouteArgs()); + orElse: () => const PlacesCollectionRouteArgs(), + ); return PlacesCollectionPage( key: args.key, currentLocation: args.currentLocation, @@ -1457,10 +1328,7 @@ class PlacesCollectionRoute extends PageRouteInfo { } class PlacesCollectionRouteArgs { - const PlacesCollectionRouteArgs({ - this.key, - this.currentLocation, - }); + const PlacesCollectionRouteArgs({this.key, this.currentLocation}); final Key? key; @@ -1476,10 +1344,7 @@ class PlacesCollectionRouteArgs { /// [RecentlyTakenPage] class RecentlyTakenRoute extends PageRouteInfo { const RecentlyTakenRoute({List? children}) - : super( - RecentlyTakenRoute.name, - initialChildren: children, - ); + : super(RecentlyTakenRoute.name, initialChildren: children); static const String name = 'RecentlyTakenRoute'; @@ -1500,10 +1365,7 @@ class SearchRoute extends PageRouteInfo { List? children, }) : super( SearchRoute.name, - args: SearchRouteArgs( - key: key, - prefilter: prefilter, - ), + args: SearchRouteArgs(key: key, prefilter: prefilter), initialChildren: children, ); @@ -1512,21 +1374,16 @@ class SearchRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = - data.argsAs(orElse: () => const SearchRouteArgs()); - return SearchPage( - key: args.key, - prefilter: args.prefilter, + final args = data.argsAs( + orElse: () => const SearchRouteArgs(), ); + return SearchPage(key: args.key, prefilter: args.prefilter); }, ); } class SearchRouteArgs { - const SearchRouteArgs({ - this.key, - this.prefilter, - }); + const SearchRouteArgs({this.key, this.prefilter}); final Key? key; @@ -1542,10 +1399,7 @@ class SearchRouteArgs { /// [SettingsPage] class SettingsRoute extends PageRouteInfo { const SettingsRoute({List? children}) - : super( - SettingsRoute.name, - initialChildren: children, - ); + : super(SettingsRoute.name, initialChildren: children); static const String name = 'SettingsRoute'; @@ -1566,10 +1420,7 @@ class SettingsSubRoute extends PageRouteInfo { List? children, }) : super( SettingsSubRoute.name, - args: SettingsSubRouteArgs( - section: section, - key: key, - ), + args: SettingsSubRouteArgs(section: section, key: key), initialChildren: children, ); @@ -1579,19 +1430,13 @@ class SettingsSubRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return SettingsSubPage( - args.section, - key: args.key, - ); + return SettingsSubPage(args.section, key: args.key); }, ); } class SettingsSubRouteArgs { - const SettingsSubRouteArgs({ - required this.section, - this.key, - }); + const SettingsSubRouteArgs({required this.section, this.key}); final SettingSection section; @@ -1612,10 +1457,7 @@ class ShareIntentRoute extends PageRouteInfo { List? children, }) : super( ShareIntentRoute.name, - args: ShareIntentRouteArgs( - key: key, - attachments: attachments, - ), + args: ShareIntentRouteArgs(key: key, attachments: attachments), initialChildren: children, ); @@ -1625,19 +1467,13 @@ class ShareIntentRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return ShareIntentPage( - key: args.key, - attachments: args.attachments, - ); + return ShareIntentPage(key: args.key, attachments: args.attachments); }, ); } class ShareIntentRouteArgs { - const ShareIntentRouteArgs({ - this.key, - required this.attachments, - }); + const ShareIntentRouteArgs({this.key, required this.attachments}); final Key? key; @@ -1675,7 +1511,8 @@ class SharedLinkEditRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs( - orElse: () => const SharedLinkEditRouteArgs()); + orElse: () => const SharedLinkEditRouteArgs(), + ); return SharedLinkEditPage( key: args.key, existingLink: args.existingLink, @@ -1712,10 +1549,7 @@ class SharedLinkEditRouteArgs { /// [SharedLinkPage] class SharedLinkRoute extends PageRouteInfo { const SharedLinkRoute({List? children}) - : super( - SharedLinkRoute.name, - initialChildren: children, - ); + : super(SharedLinkRoute.name, initialChildren: children); static const String name = 'SharedLinkRoute'; @@ -1731,10 +1565,7 @@ class SharedLinkRoute extends PageRouteInfo { /// [SplashScreenPage] class SplashScreenRoute extends PageRouteInfo { const SplashScreenRoute({List? children}) - : super( - SplashScreenRoute.name, - initialChildren: children, - ); + : super(SplashScreenRoute.name, initialChildren: children); static const String name = 'SplashScreenRoute'; @@ -1750,10 +1581,7 @@ class SplashScreenRoute extends PageRouteInfo { /// [TabControllerPage] class TabControllerRoute extends PageRouteInfo { const TabControllerRoute({List? children}) - : super( - TabControllerRoute.name, - initialChildren: children, - ); + : super(TabControllerRoute.name, initialChildren: children); static const String name = 'TabControllerRoute'; @@ -1769,10 +1597,7 @@ class TabControllerRoute extends PageRouteInfo { /// [TrashPage] class TrashRoute extends PageRouteInfo { const TrashRoute({List? children}) - : super( - TrashRoute.name, - initialChildren: children, - ); + : super(TrashRoute.name, initialChildren: children); static const String name = 'TrashRoute'; diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 4f95e657d95..09f81b9e1a1 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; -import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -180,10 +179,10 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { child: action, ), ), - if (kDebugMode) + if (kDebugMode || kProfileMode) IconButton( - onPressed: () => ref.read(backgroundSyncProvider).sync(), - icon: const Icon(Icons.sync), + icon: const Icon(Icons.science_rounded), + onPressed: () => context.pushRoute(const FeatInDevRoute()), ), if (showUploadButton) Padding( diff --git a/mobile/makefile b/mobile/makefile index b0083b1495b..b797a659283 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -1,7 +1,13 @@ -.PHONY: build watch create_app_icon create_splash build_release_android +.PHONY: build watch create_app_icon create_splash build_release_android pigeon build: dart run build_runner build --delete-conflicting-outputs +# Remove once auto_route updated to 10.1.0 + dart format lib/routing/router.gr.dart + +pigeon: + dart run pigeon --input pigeon/native_sync_api.dart + dart format lib/platform/native_sync_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs @@ -19,4 +25,5 @@ migrations: dart run drift_dev make-migrations translation: - dart run easy_localization:generate -S ../i18n \ No newline at end of file + dart run easy_localization:generate -S ../i18n + dart format lib/generated/codegen_loader.g.dart \ No newline at end of file diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart new file mode 100644 index 00000000000..b8a7500d6e5 --- /dev/null +++ b/mobile/pigeon/native_sync_api.dart @@ -0,0 +1,89 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/native_sync_api.g.dart', + swiftOut: 'ios/Runner/Sync/Messages.g.swift', + swiftOptions: SwiftOptions(), + kotlinOut: + 'android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.sync'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +class PlatformAsset { + final String id; + final String name; + // Follows AssetType enum from base_asset.model.dart + final int type; + // Seconds since epoch + final int? createdAt; + final int? updatedAt; + final int durationInSeconds; + + const PlatformAsset({ + required this.id, + required this.name, + required this.type, + this.createdAt, + this.updatedAt, + this.durationInSeconds = 0, + }); +} + +class PlatformAlbum { + final String id; + final String name; + // Seconds since epoch + final int? updatedAt; + final bool isCloud; + final int assetCount; + + const PlatformAlbum({ + required this.id, + required this.name, + this.updatedAt, + this.isCloud = false, + this.assetCount = 0, + }); +} + +class SyncDelta { + final bool hasChanges; + final List updates; + final List deletes; + // Asset -> Album mapping + final Map> assetAlbums; + + const SyncDelta({ + this.hasChanges = false, + this.updates = const [], + this.deletes = const [], + this.assetAlbums = const {}, + }); +} + +@HostApi() +abstract class NativeSyncApi { + bool shouldFullSync(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + SyncDelta getMediaChanges(); + + void checkpointSync(); + + void clearSyncCheckpoint(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + List getAssetIdsForAlbum(String albumId); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + List getAlbums(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + int getAssetsCountSince(String albumId, int timestamp); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + List getAssetsForAlbum(String albumId, {int? updatedTimeCond}); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 3df4e4e8a98..5c54a2c349d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -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: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" url: "https://pub.dev" source: hosted - version: "2.3.8" + version: "3.1.0" dartx: dependency: transitive description: @@ -723,10 +726,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: @@ -971,10 +974,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/immich-app/isar" + source: git version: "3.1.8" js: dependency: transitive @@ -1072,14 +1076,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: @@ -1121,7 +1117,7 @@ packages: source: hosted version: "0.11.1" meta: - dependency: "direct overridden" + dependency: transitive description: name: meta sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c @@ -1352,6 +1348,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pigeon: + dependency: "direct dev" + description: + name: pigeon + sha256: a093af76026160bb5ff6eb98e3e678a301ffd1001ac0d90be558bc133a0c73f5 + url: "https://pub.dev" + source: hosted + version: "25.3.2" pinput: dependency: "direct main" description: @@ -1361,7 +1365,7 @@ packages: source: hosted version: "5.0.1" platform: - dependency: transitive + dependency: "direct main" description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" @@ -1444,10 +1448,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: @@ -1460,18 +1464,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: @@ -1633,10 +1637,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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6dd81b7fc19..81249fdcfa0 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: flutter_displaymode: ^0.6.0 flutter_hooks: ^0.21.2 flutter_local_notifications: ^17.2.1+2 + flutter_secure_storage: ^9.2.4 flutter_svg: ^2.0.17 flutter_udid: ^3.0.0 flutter_web_auth_2: ^5.0.0-alpha.0 @@ -41,6 +42,7 @@ dependencies: http: ^1.3.0 image_picker: ^1.1.2 intl: ^0.19.0 + local_auth: ^2.3.0 logging: ^1.3.0 maplibre_gl: ^0.21.0 network_info_plus: ^6.1.3 @@ -52,6 +54,8 @@ dependencies: permission_handler: ^11.4.0 photo_manager: ^3.6.4 photo_manager_image_provider: ^2.2.0 + pinput: ^5.0.1 + platform: ^3.1.6 punycode: ^1.0.0 riverpod_annotation: ^2.6.1 scrollable_positioned_list: ^0.3.8 @@ -64,9 +68,6 @@ dependencies: uuid: ^4.5.1 wakelock_plus: ^1.2.10 worker_manager: ^7.2.3 - local_auth: ^2.3.0 - pinput: ^5.0.1 - flutter_secure_storage: ^9.2.4 native_video_player: git: @@ -84,11 +85,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 @@ -98,11 +94,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/immich-app/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 @@ -112,6 +110,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