From c91a2878dce664743925adddd6caea94c90fa33c Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Thu, 17 Oct 2024 23:33:00 +0530 Subject: [PATCH] feat: full local assets / album sync --- mobile-v2/android/app/build.gradle | 17 +- .../android/app/src/main/AndroidManifest.xml | 5 + .../com/alextran/immich/ImAppGlideModule.kt | 7 + .../com/alextran/immich/MainActivity.kt | 8 +- .../alextran/immich/platform/MessagesImpl.kt | 38 ++--- mobile-v2/android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- mobile-v2/android/settings.gradle | 5 +- .../ios/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- mobile-v2/ios/Runner/AppDelegate.swift | 2 +- .../ios/Runner/Platform/MessagesImpl.swift | 29 ++-- .../lib/domain/entities/album.entity.dart | 16 +- .../domain/entities/album_asset.entity.dart | 16 ++ .../domain/entities/album_etag.entity.dart | 14 ++ .../entities/device_asset_hash.entity.dart | 14 ++ .../domain/interfaces/album.interface.dart | 14 ++ .../interfaces/album_asset.interface.dart | 17 ++ .../interfaces/album_etag.interface.dart | 11 ++ .../domain/interfaces/asset.interface.dart | 9 +- .../domain/interfaces/database.interface.dart | 4 + .../interfaces/device_album.interface.dart | 22 +++ .../interfaces/device_asset.interface.dart | 24 +++ .../device_asset_hash.interface.dart | 14 ++ .../lib/domain/interfaces/log.interface.dart | 2 +- mobile-v2/lib/domain/models/album.model.dart | 69 ++++++-- .../lib/domain/models/album_etag.model.dart | 38 +++++ .../lib/domain/models/app_setting.model.dart | 8 +- mobile-v2/lib/domain/models/asset.model.dart | 44 +++--- .../models/device_asset_download.model.dart | 56 +++++++ .../models/device_asset_hash.model.dart | 57 +++++++ mobile-v2/lib/domain/models/log.model.dart | 1 - mobile-v2/lib/domain/models/store.model.dart | 14 +- .../domain/repositories/album.repository.dart | 76 +++++++++ .../repositories/album_asset.repository.dart | 78 +++++++++ .../repositories/album_etag.repository.dart | 56 +++++++ .../domain/repositories/asset.repository.dart | 64 ++++---- .../repositories/database.repository.dart | 25 ++- .../repositories/device_album.repository.dart | 83 ++++++++++ .../repositories/device_asset.repository.dart | 100 ++++++++++++ .../device_asset_hash.repository.dart | 62 ++++++++ .../domain/repositories/log.repository.dart | 38 ++--- .../repositories/renderlist.repository.dart | 4 +- .../domain/repositories/store.repository.dart | 4 +- .../domain/repositories/user.repository.dart | 4 +- .../domain/services/album_sync.service.dart | 131 +++++++++++++++ .../domain/services/asset_sync.service.dart | 143 +++++++++-------- .../lib/domain/services/hash.service.dart | 149 ++++++++++++++++++ .../lib/domain/services/login.service.dart | 14 +- .../domain/utils/drift_model_converters.dart | 21 +++ mobile-v2/lib/main.dart | 3 + mobile-v2/lib/platform/messages.dart | 4 +- .../grid/immich_asset_grid.state.dart | 4 +- .../grid/immich_asset_grid.widget.dart | 13 +- .../grid/immich_asset_grid_header.widget.dart | 7 +- .../immich_grid_asset_placeholder.widget.dart | 25 --- .../components/image/cache/cache_manager.dart | 40 +++++ .../components/image/cache/image_loader.dart | 55 +++++++ .../components/image/immich_image.widget.dart | 99 ++++++++---- .../components/image/immich_logo.widget.dart | 1 + .../image/immich_thumbnail.widget.dart | 87 ++++++++++ .../provider/immich_local_image_provider.dart | 109 +++++++++++++ .../immich_local_thumbnail_provider.dart | 88 +++++++++++ .../immich_remote_image_provider.dart | 88 +++++++++++ .../immich_remote_thumbnail_provider.dart | 80 ++++++++++ .../adaptive_route_appbar.widget.dart | 1 + .../modules/home/pages/home.page.dart | 2 +- .../login/states/login_page.state.dart | 10 +- .../login/widgets/login_form.widget.dart | 2 +- .../models/settings_section.model.dart | 8 +- .../settings/pages/about_settings.page.dart | 8 +- .../modules/settings/pages/settings.page.dart | 1 + .../modules/theme/models/app_theme.model.dart | 14 +- .../router/pages/splash_screen.page.dart | 6 +- .../states/current_user.state.dart | 0 .../states/gallery_permission.state.dart | 123 +++++++++++++++ .../server_feature_config.state.dart | 0 mobile-v2/lib/service_locator.dart | 65 ++++++-- mobile-v2/lib/utils/collection_util.dart | 43 +++-- mobile-v2/lib/utils/constants/globals.dart | 17 +- .../lib/utils/extensions/file.extension.dart | 11 ++ mobile-v2/lib/utils/immich_api_client.dart | 4 +- .../lib/utils/immich_image_url_helper.dart | 20 ++- mobile-v2/lib/utils/isolate_helper.dart | 15 +- mobile-v2/lib/utils/log_manager.dart | 39 +++-- mobile-v2/pubspec.lock | 50 +++++- mobile-v2/pubspec.yaml | 6 +- 87 files changed, 2417 insertions(+), 366 deletions(-) create mode 100644 mobile-v2/android/app/src/main/kotlin/com/alextran/immich/ImAppGlideModule.kt create mode 100644 mobile-v2/lib/domain/entities/album_asset.entity.dart create mode 100644 mobile-v2/lib/domain/entities/album_etag.entity.dart create mode 100644 mobile-v2/lib/domain/entities/device_asset_hash.entity.dart create mode 100644 mobile-v2/lib/domain/interfaces/album.interface.dart create mode 100644 mobile-v2/lib/domain/interfaces/album_asset.interface.dart create mode 100644 mobile-v2/lib/domain/interfaces/album_etag.interface.dart create mode 100644 mobile-v2/lib/domain/interfaces/database.interface.dart create mode 100644 mobile-v2/lib/domain/interfaces/device_album.interface.dart create mode 100644 mobile-v2/lib/domain/interfaces/device_asset.interface.dart create mode 100644 mobile-v2/lib/domain/interfaces/device_asset_hash.interface.dart create mode 100644 mobile-v2/lib/domain/models/album_etag.model.dart create mode 100644 mobile-v2/lib/domain/models/device_asset_download.model.dart create mode 100644 mobile-v2/lib/domain/models/device_asset_hash.model.dart create mode 100644 mobile-v2/lib/domain/repositories/album.repository.dart create mode 100644 mobile-v2/lib/domain/repositories/album_asset.repository.dart create mode 100644 mobile-v2/lib/domain/repositories/album_etag.repository.dart create mode 100644 mobile-v2/lib/domain/repositories/device_album.repository.dart create mode 100644 mobile-v2/lib/domain/repositories/device_asset.repository.dart create mode 100644 mobile-v2/lib/domain/repositories/device_asset_hash.repository.dart create mode 100644 mobile-v2/lib/domain/services/album_sync.service.dart create mode 100644 mobile-v2/lib/domain/services/hash.service.dart create mode 100644 mobile-v2/lib/domain/utils/drift_model_converters.dart delete mode 100644 mobile-v2/lib/presentation/components/grid/immich_grid_asset_placeholder.widget.dart create mode 100644 mobile-v2/lib/presentation/components/image/cache/cache_manager.dart create mode 100644 mobile-v2/lib/presentation/components/image/cache/image_loader.dart create mode 100644 mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart create mode 100644 mobile-v2/lib/presentation/components/image/provider/immich_local_image_provider.dart create mode 100644 mobile-v2/lib/presentation/components/image/provider/immich_local_thumbnail_provider.dart create mode 100644 mobile-v2/lib/presentation/components/image/provider/immich_remote_image_provider.dart create mode 100644 mobile-v2/lib/presentation/components/image/provider/immich_remote_thumbnail_provider.dart rename mobile-v2/lib/presentation/{modules/common => }/states/current_user.state.dart (100%) create mode 100644 mobile-v2/lib/presentation/states/gallery_permission.state.dart rename mobile-v2/lib/presentation/{modules/common => }/states/server_info/server_feature_config.state.dart (100%) create mode 100644 mobile-v2/lib/utils/extensions/file.extension.dart diff --git a/mobile-v2/android/app/build.gradle b/mobile-v2/android/app/build.gradle index d355694032..59e099123d 100644 --- a/mobile-v2/android/app/build.gradle +++ b/mobile-v2/android/app/build.gradle @@ -2,6 +2,7 @@ plugins { id "com.android.application" id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" + id 'com.google.devtools.ksp' } def localProperties = new Properties() @@ -25,15 +26,14 @@ if (flutterVersionName == null) { android { namespace "com.alextran.immich" compileSdkVersion 34 - ndkVersion flutter.ndkVersion compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } sourceSets { @@ -64,4 +64,11 @@ flutter { source '../..' } -dependencies {} +dependencies { + def glide_version = '4.16.0' + def kotlin_version = '2.0.20' + + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + api "com.github.bumptech.glide:glide:$glide_version" + ksp "com.github.bumptech.glide:ksp:$glide_version" +} \ No newline at end of file diff --git a/mobile-v2/android/app/src/main/AndroidManifest.xml b/mobile-v2/android/app/src/main/AndroidManifest.xml index c6abaa296a..bc3e83e355 100644 --- a/mobile-v2/android/app/src/main/AndroidManifest.xml +++ b/mobile-v2/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,11 @@ + + + diff --git a/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/ImAppGlideModule.kt b/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/ImAppGlideModule.kt new file mode 100644 index 0000000000..575e5b8f77 --- /dev/null +++ b/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/ImAppGlideModule.kt @@ -0,0 +1,7 @@ +package com.alextran.immich + +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.module.AppGlideModule + +@GlideModule +class ImAppGlideModule : AppGlideModule() \ No newline at end of file diff --git a/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/MainActivity.kt b/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/MainActivity.kt index 00c66f75cc..91fb9a2bbb 100644 --- a/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/MainActivity.kt +++ b/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/MainActivity.kt @@ -1,14 +1,14 @@ package com.alextran.immich -import ImmichHostService -import com.alextran.immich.platform.ImmichHostServiceImpl +import ImHostService +import com.alextran.immich.platform.ImHostServiceImpl import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine class MainActivity: FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - // Register piegon handler - ImmichHostService.setUp(flutterEngine.dartExecutor.binaryMessenger, ImmichHostServiceImpl()) + // Register pigeon handler + ImHostService.setUp(flutterEngine.dartExecutor.binaryMessenger, ImHostServiceImpl()) super.configureFlutterEngine(flutterEngine) } diff --git a/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/platform/MessagesImpl.kt b/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/platform/MessagesImpl.kt index f001de754a..821050432d 100644 --- a/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/platform/MessagesImpl.kt +++ b/mobile-v2/android/app/src/main/kotlin/com/alextran/immich/platform/MessagesImpl.kt @@ -1,6 +1,6 @@ package com.alextran.immich.platform -import ImmichHostService +import ImHostService import android.util.Log import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -9,39 +9,35 @@ import kotlinx.coroutines.launch import java.io.FileInputStream import java.security.MessageDigest -class ImmichHostServiceImpl: ImmichHostService { +class ImHostServiceImpl(): ImHostService { @OptIn(DelicateCoroutinesApi::class) - override fun digestFiles(paths: List, callback: (Result?>) -> Unit) { + override fun digestFiles(paths: List, callback: (Result>) -> Unit) { GlobalScope.launch(Dispatchers.IO) { - val buf = ByteArray(Companion.BUFFER_SIZE) val digest: MessageDigest = MessageDigest.getInstance("SHA-1") - val hashes = arrayOfNulls(paths.size) - for (i in paths.indices) { - val path = paths[i] - var len = 0 + val buffer = ByteArray(BUFFER_SIZE) + + val hashes = paths.map { path -> try { - val file = FileInputStream(path) - file.use { assetFile -> - while (true) { - len = assetFile.read(buf) - if (len != Companion.BUFFER_SIZE) break - digest.update(buf) + FileInputStream(path).use { inputStream -> + digest.reset() + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) } + digest.digest() } - digest.update(buf, 0, len) - hashes[i] = digest.digest() } catch (e: Exception) { - // skip this file - Log.w(TAG, "Failed to hash file ${paths[i]}: $e") + Log.e(TAG, "Failed to hash file $path", e) + null } } - callback(Result.success(hashes.asList())) + callback(Result.success(hashes)) } } companion object { - private const val BUFFER_SIZE = 2 * 1024 * 1024; - private const val TAG = "ImmichHostServiceImpl" + private const val BUFFER_SIZE = 8192 // 8KB buffer + private const val TAG = "ImHostServiceImpl" } } diff --git a/mobile-v2/android/gradle.properties b/mobile-v2/android/gradle.properties index 598d13fee4..157da377a5 100644 --- a/mobile-v2/android/gradle.properties +++ b/mobile-v2/android/gradle.properties @@ -1,3 +1,5 @@ org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/mobile-v2/android/gradle/wrapper/gradle-wrapper.properties b/mobile-v2/android/gradle/wrapper/gradle-wrapper.properties index e1ca574ef0..348c409eab 100644 --- a/mobile-v2/android/gradle/wrapper/gradle-wrapper.properties +++ b/mobile-v2/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip diff --git a/mobile-v2/android/settings.gradle b/mobile-v2/android/settings.gradle index 1d6d19b7f8..45337763aa 100644 --- a/mobile-v2/android/settings.gradle +++ b/mobile-v2/android/settings.gradle @@ -19,8 +19,9 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "com.android.application" version '8.7.1' 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 } include ":app" diff --git a/mobile-v2/ios/Runner.xcodeproj/project.pbxproj b/mobile-v2/ios/Runner.xcodeproj/project.pbxproj index 54abe03cd3..ffcf774081 100644 --- a/mobile-v2/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile-v2/ios/Runner.xcodeproj/project.pbxproj @@ -210,7 +210,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1540; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C8080294A63A400263BE5 = { diff --git a/mobile-v2/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile-v2/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 550ef0c1fd..8e3ca5dfe1 100644 --- a/mobile-v2/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/mobile-v2/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ ) -> Void) { +class ImHostServiceImpl: ImHostService { + func digestFiles(paths: [String], completion: @escaping (Result<[FlutterStandardTypedData?], Error>) -> Void) { let bufsize = 2 * 1024 * 1024 - // Private error to throw if file cannot be read - enum DigestError: String, LocalizedError { - case NoFileHandle = "Cannot Open File Handle" - - public var errorDescription: String? { self.rawValue } - } // Compute hash in background thread DispatchQueue.global(qos: .background).async { - var hashes: [FlutterStandardTypedData?] = Array(repeating: nil, count: paths.count) - for i in (0 ..< paths.count) { + let hashes = paths.map { path -> FlutterStandardTypedData? in do { - guard let file = FileHandle(forReadingAtPath: paths[i]) else { throw DigestError.NoFileHandle } - var hasher = Insecure.SHA1.init(); + guard let file = FileHandle(forReadingAtPath: path) else { + throw NSError(domain: "ImHostServiceImpl", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot Open File Handle"]) + } + defer { file.closeFile() } + + var hasher = Insecure.SHA1() while autoreleasepool(invoking: { let chunk = file.readData(ofLength: bufsize) guard !chunk.isEmpty else { return false } // EOF hasher.update(data: chunk) return true // continue }) { } + let digest = hasher.finalize() - hashes[i] = FlutterStandardTypedData(bytes: Data(Array(digest.makeIterator()))) + return FlutterStandardTypedData(bytes: Data(digest)) } catch { - print("Cannot calculate the digest of the file \(paths[i]) due to \(error.localizedDescription)") + print("Cannot calculate the digest of the file \(path) due to \(error.localizedDescription)") + return nil } } // Return result in main thread DispatchQueue.main.async { - completion(.success(Array(hashes))) + completion(.success(hashes)) } } } diff --git a/mobile-v2/lib/domain/entities/album.entity.dart b/mobile-v2/lib/domain/entities/album.entity.dart index 077e518fee..edec6df4a5 100644 --- a/mobile-v2/lib/domain/entities/album.entity.dart +++ b/mobile-v2/lib/domain/entities/album.entity.dart @@ -1,11 +1,21 @@ import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/entities/asset.entity.dart'; -class LocalAlbum extends Table { - const LocalAlbum(); +class Album extends Table { + const Album(); IntColumn get id => integer().autoIncrement()(); - TextColumn get localId => text().unique()(); TextColumn get name => text()(); DateTimeColumn get modifiedTime => dateTime().withDefault(currentDateAndTime)(); + + IntColumn get thumbnailAssetId => integer() + .references(Asset, #id, onDelete: KeyAction.setNull) + .nullable()(); + + // Local only + TextColumn get localId => text().nullable().unique()(); + + // Remote only + TextColumn get remoteId => text().nullable().unique()(); } diff --git a/mobile-v2/lib/domain/entities/album_asset.entity.dart b/mobile-v2/lib/domain/entities/album_asset.entity.dart new file mode 100644 index 0000000000..7c9999f8b1 --- /dev/null +++ b/mobile-v2/lib/domain/entities/album_asset.entity.dart @@ -0,0 +1,16 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/entities/album.entity.dart'; +import 'package:immich_mobile/domain/entities/asset.entity.dart'; + +class AlbumToAsset extends Table { + const AlbumToAsset(); + + IntColumn get assetId => + integer().references(Asset, #id, onDelete: KeyAction.cascade)(); + + IntColumn get albumId => + integer().references(Album, #id, onDelete: KeyAction.cascade)(); + + @override + Set get primaryKey => {assetId, albumId}; +} diff --git a/mobile-v2/lib/domain/entities/album_etag.entity.dart b/mobile-v2/lib/domain/entities/album_etag.entity.dart new file mode 100644 index 0000000000..81fd088427 --- /dev/null +++ b/mobile-v2/lib/domain/entities/album_etag.entity.dart @@ -0,0 +1,14 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/entities/album.entity.dart'; + +class AlbumETag extends Table { + const AlbumETag(); + + IntColumn get id => integer().autoIncrement()(); + + IntColumn get albumId => + integer().references(Album, #id, onDelete: KeyAction.cascade).unique()(); + DateTimeColumn get modifiedTime => + dateTime().withDefault(currentDateAndTime)(); + IntColumn get assetCount => integer().withDefault(const Constant(0))(); +} diff --git a/mobile-v2/lib/domain/entities/device_asset_hash.entity.dart b/mobile-v2/lib/domain/entities/device_asset_hash.entity.dart new file mode 100644 index 0000000000..6bcf707fc6 --- /dev/null +++ b/mobile-v2/lib/domain/entities/device_asset_hash.entity.dart @@ -0,0 +1,14 @@ +import 'package:drift/drift.dart'; + +@TableIndex(name: 'deviceassethash_localId', columns: {#localId}) +@TableIndex(name: 'deviceassethash_hash', columns: {#hash}) +class DeviceAssetToHash extends Table { + const DeviceAssetToHash(); + + IntColumn get id => integer().autoIncrement()(); + + TextColumn get localId => text().unique()(); + TextColumn get hash => text()(); + DateTimeColumn get modifiedTime => + dateTime().withDefault(currentDateAndTime)(); +} diff --git a/mobile-v2/lib/domain/interfaces/album.interface.dart b/mobile-v2/lib/domain/interfaces/album.interface.dart new file mode 100644 index 0000000000..42d62a2249 --- /dev/null +++ b/mobile-v2/lib/domain/interfaces/album.interface.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/album.model.dart'; + +abstract interface class IAlbumRepository { + /// Inserts a new album into the DB or updates if existing and returns the updated data + FutureOr upsert(Album album); + + /// Fetch all albums + FutureOr> getAll({bool localOnly, bool remoteOnly}); + + /// Removes album with the given [id] + FutureOr deleteId(int id); +} diff --git a/mobile-v2/lib/domain/interfaces/album_asset.interface.dart b/mobile-v2/lib/domain/interfaces/album_asset.interface.dart new file mode 100644 index 0000000000..cdb5ba555c --- /dev/null +++ b/mobile-v2/lib/domain/interfaces/album_asset.interface.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/asset.model.dart'; + +abstract interface class IAlbumToAssetRepository { + /// Link a list of assetIds to the given albumId + FutureOr addAssetIds(int albumId, Iterable assetIds); + + /// Returns assets that are only part of the given album and nothing else + FutureOr> getAssetIdsOnlyInAlbum(int albumId); + + /// Returns the assets for the given [albumId] + FutureOr> getAssetsForAlbum(int albumId); + + /// Removes album with the given [albumId] + FutureOr deleteAlbumId(int albumId); +} diff --git a/mobile-v2/lib/domain/interfaces/album_etag.interface.dart b/mobile-v2/lib/domain/interfaces/album_etag.interface.dart new file mode 100644 index 0000000000..e95162fd6b --- /dev/null +++ b/mobile-v2/lib/domain/interfaces/album_etag.interface.dart @@ -0,0 +1,11 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/album_etag.model.dart'; + +abstract interface class IAlbumETagRepository { + /// Inserts or updates the album etag for the given [albumId] + FutureOr upsert(AlbumETag albumETag); + + /// Fetches the album etag for the given [albumId] + FutureOr get(int albumId); +} diff --git a/mobile-v2/lib/domain/interfaces/asset.interface.dart b/mobile-v2/lib/domain/interfaces/asset.interface.dart index 911ef48f69..fa43a3790b 100644 --- a/mobile-v2/lib/domain/interfaces/asset.interface.dart +++ b/mobile-v2/lib/domain/interfaces/asset.interface.dart @@ -7,16 +7,19 @@ abstract interface class IAssetRepository { FutureOr upsertAll(Iterable assets); /// Removes assets with the [localIds] - FutureOr> getForLocalIds(List localIds); + FutureOr> getForLocalIds(Iterable localIds); /// Removes assets with the [remoteIds] - FutureOr> getForRemoteIds(List remoteIds); + FutureOr> getForRemoteIds(Iterable remoteIds); + + /// Get assets with the [hashes] + FutureOr> getForHashes(Iterable hashes); /// Fetch assets from the [offset] with the [limit] FutureOr> getAll({int? offset, int? limit}); /// Removes assets with the given [ids] - FutureOr deleteIds(List ids); + FutureOr deleteIds(Iterable ids); /// Removes all assets FutureOr deleteAll(); diff --git a/mobile-v2/lib/domain/interfaces/database.interface.dart b/mobile-v2/lib/domain/interfaces/database.interface.dart new file mode 100644 index 0000000000..b95d21357a --- /dev/null +++ b/mobile-v2/lib/domain/interfaces/database.interface.dart @@ -0,0 +1,4 @@ +abstract interface class IDatabaseRepository { + /// Runs the [action] in a transaction + Future txn(Future Function() action); +} diff --git a/mobile-v2/lib/domain/interfaces/device_album.interface.dart b/mobile-v2/lib/domain/interfaces/device_album.interface.dart new file mode 100644 index 0000000000..0d54b5878e --- /dev/null +++ b/mobile-v2/lib/domain/interfaces/device_album.interface.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/album.model.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; + +abstract interface class IDeviceAlbumRepository { + /// Fetches all [Album] from device + FutureOr> getAll(); + + /// Returns the number of asset in the album + FutureOr getAssetCount(String albumId); + + /// Fetches assets belong to the albumId + FutureOr> getAssetsForAlbum( + String albumId, { + int start = 0, + int end = 0x7fffffffffffffff, + DateTime? modifiedFrom, + DateTime? modifiedUntil, + bool orderByModificationDate = false, + }); +} diff --git a/mobile-v2/lib/domain/interfaces/device_asset.interface.dart b/mobile-v2/lib/domain/interfaces/device_asset.interface.dart new file mode 100644 index 0000000000..bce5c7e20f --- /dev/null +++ b/mobile-v2/lib/domain/interfaces/device_asset.interface.dart @@ -0,0 +1,24 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/domain/models/device_asset_download.model.dart'; +import 'package:immich_mobile/utils/constants/globals.dart'; + +abstract interface class IDeviceAssetRepository { + /// Fetches the [File] for the given [assetId] + FutureOr getOriginalFile(String assetId); + + /// Fetches the thumbnail for the given [assetId] + FutureOr getThumbnail( + String assetId, { + int width = kGridThumbnailSize, + int height = kGridThumbnailSize, + int quality = kGridThumbnailQuality, + DeviceAssetDownloadHandler? downloadHandler, + }); + + /// Converts the given [entity] to an [Asset] + Future toAsset(T entity); +} diff --git a/mobile-v2/lib/domain/interfaces/device_asset_hash.interface.dart b/mobile-v2/lib/domain/interfaces/device_asset_hash.interface.dart new file mode 100644 index 0000000000..5fa7b083f5 --- /dev/null +++ b/mobile-v2/lib/domain/interfaces/device_asset_hash.interface.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/device_asset_hash.model.dart'; + +abstract interface class IDeviceAssetToHashRepository { + /// Add a new device asset to hash entry + FutureOr upsertAll(Iterable assetHash); + + // Gets the asset with the local ID from the device + FutureOr> getForIds(Iterable localIds); + + /// Removes assets with the given [ids] + FutureOr deleteIds(Iterable ids); +} diff --git a/mobile-v2/lib/domain/interfaces/log.interface.dart b/mobile-v2/lib/domain/interfaces/log.interface.dart index 711e910c38..82742a5bd2 100644 --- a/mobile-v2/lib/domain/interfaces/log.interface.dart +++ b/mobile-v2/lib/domain/interfaces/log.interface.dart @@ -7,7 +7,7 @@ abstract interface class ILogRepository { FutureOr create(LogMessage log); /// Bulk insert logs into DB - FutureOr createAll(List log); + FutureOr createAll(Iterable log); /// Fetches all logs FutureOr> getAll(); diff --git a/mobile-v2/lib/domain/models/album.model.dart b/mobile-v2/lib/domain/models/album.model.dart index d8131e5d4f..65198128ac 100644 --- a/mobile-v2/lib/domain/models/album.model.dart +++ b/mobile-v2/lib/domain/models/album.model.dart @@ -1,31 +1,82 @@ import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/utils/collection_util.dart'; @immutable -class LocalAlbum { - final int id; - final String localId; +class Album { + final int? id; + final String? localId; + final String? remoteId; final String name; final DateTime modifiedTime; + final int? thumbnailAssetId; - const LocalAlbum({ - required this.id, - required this.localId, + bool get isRemote => remoteId != null; + bool get isLocal => localId != null; + + const Album({ + this.id, + this.localId, + this.remoteId, required this.name, required this.modifiedTime, + this.thumbnailAssetId, }); @override - bool operator ==(covariant LocalAlbum other) { + bool operator ==(covariant Album other) { if (identical(this, other)) return true; - return other.hashCode == hashCode; + return other.id == id && + other.localId == localId && + other.remoteId == remoteId && + other.name == name && + other.modifiedTime == modifiedTime && + other.thumbnailAssetId == thumbnailAssetId; } @override int get hashCode { return id.hashCode ^ localId.hashCode ^ + remoteId.hashCode ^ name.hashCode ^ - modifiedTime.hashCode; + modifiedTime.hashCode ^ + thumbnailAssetId.hashCode; } + + Album copyWith({ + int? id, + String? localId, + String? remoteId, + String? name, + DateTime? modifiedTime, + int? thumbnailAssetId, + }) { + return Album( + id: id ?? this.id, + localId: localId ?? this.localId, + remoteId: remoteId ?? this.remoteId, + name: name ?? this.name, + modifiedTime: modifiedTime ?? this.modifiedTime, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + ); + } + + @override + String toString() => """ +{ + id: ${id ?? "-"}, + localId: "${localId ?? "-"}", + remoteId: "${remoteId ?? "-"}", + name: $name, + modifiedTime: + $modifiedTime, + thumbnailAssetId: "${thumbnailAssetId ?? "-"}", +}"""; + + static int compareByLocalId(Album a, Album b) => + CollectionUtil.compareToNullable(a.localId, b.localId); + + static int compareByRemoteId(Album a, Album b) => + CollectionUtil.compareToNullable(a.remoteId, b.remoteId); } diff --git a/mobile-v2/lib/domain/models/album_etag.model.dart b/mobile-v2/lib/domain/models/album_etag.model.dart new file mode 100644 index 0000000000..03fa10eec0 --- /dev/null +++ b/mobile-v2/lib/domain/models/album_etag.model.dart @@ -0,0 +1,38 @@ +class AlbumETag { + final int? id; + final int albumId; + final int assetCount; + final DateTime modifiedTime; + + const AlbumETag({ + this.id, + required this.albumId, + required this.assetCount, + required this.modifiedTime, + }); + + factory AlbumETag.empty() { + return AlbumETag( + albumId: -1, + assetCount: 0, + modifiedTime: DateTime.now(), + ); + } + + @override + bool operator ==(covariant AlbumETag other) { + if (identical(this, other)) return true; + + return other.id == id && + other.albumId == albumId && + other.assetCount == assetCount && + other.modifiedTime == modifiedTime; + } + + @override + int get hashCode => + id.hashCode ^ + albumId.hashCode ^ + assetCount.hashCode ^ + modifiedTime.hashCode; +} diff --git a/mobile-v2/lib/domain/models/app_setting.model.dart b/mobile-v2/lib/domain/models/app_setting.model.dart index bfb1953041..eed1898a68 100644 --- a/mobile-v2/lib/domain/models/app_setting.model.dart +++ b/mobile-v2/lib/domain/models/app_setting.model.dart @@ -6,11 +6,11 @@ import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model. // This model is the only exclusion which refers to entities from the presentation layer // as well as the domain layer enum AppSetting { - appTheme(StoreKey.appTheme, AppTheme.blue), - themeMode(StoreKey.themeMode, ThemeMode.system), - darkMode(StoreKey.darkMode, false); + appTheme._(StoreKey.appTheme, AppTheme.blue), + themeMode._(StoreKey.themeMode, ThemeMode.system), + darkMode._(StoreKey.darkMode, false); - const AppSetting(this.storeKey, this.defaultValue); + const AppSetting._(this.storeKey, this.defaultValue); // ignore: avoid-dynamic final StoreKey storeKey; diff --git a/mobile-v2/lib/domain/models/asset.model.dart b/mobile-v2/lib/domain/models/asset.model.dart index 62147a24fa..4b4f5146fd 100644 --- a/mobile-v2/lib/domain/models/asset.model.dart +++ b/mobile-v2/lib/domain/models/asset.model.dart @@ -12,7 +12,7 @@ enum AssetType { } class Asset { - final int id; + final int? id; final String name; final String hash; final int? height; @@ -32,9 +32,10 @@ class Asset { bool get isRemote => remoteId != null; bool get isLocal => localId != null; bool get isMerged => isRemote && isLocal; + bool get isImage => type == AssetType.image; const Asset({ - required this.id, + this.id, required this.name, required this.hash, this.height, @@ -49,7 +50,6 @@ class Asset { }); factory Asset.remote(AssetResponseDto dto) => Asset( - id: 0, // assign a temporary auto gen ID remoteId: dto.id, createdTime: dto.fileCreatedAt, duration: dto.duration.tryParseInt() ?? 0, @@ -93,29 +93,38 @@ class Asset { } Asset merge(Asset newAsset) { - if (newAsset.modifiedTime.isAfter(modifiedTime)) { + final existingAsset = this; + assert(existingAsset.id != null, "Existing asset must be from the db"); + + final oldestCreationTime = + existingAsset.createdTime.isBefore(newAsset.createdTime) + ? existingAsset.createdTime + : newAsset.createdTime; + + if (newAsset.modifiedTime.isAfter(existingAsset.modifiedTime)) { return newAsset.copyWith( - height: newAsset.height ?? height, - width: newAsset.width ?? width, - localId: () => newAsset.localId ?? localId, - remoteId: () => newAsset.remoteId ?? remoteId, - livePhotoVideoId: newAsset.livePhotoVideoId ?? livePhotoVideoId, + id: newAsset.id ?? existingAsset.id, + localId: () => existingAsset.localId ?? newAsset.localId, + remoteId: () => existingAsset.remoteId ?? newAsset.remoteId, + width: newAsset.width ?? existingAsset.width, + height: newAsset.height ?? existingAsset.height, + createdTime: oldestCreationTime, ); } - return copyWith( - height: height ?? newAsset.height, - width: width ?? newAsset.width, - localId: () => localId ?? newAsset.localId, - remoteId: () => remoteId ?? newAsset.remoteId, - livePhotoVideoId: livePhotoVideoId ?? newAsset.livePhotoVideoId, + return existingAsset.copyWith( + localId: () => existingAsset.localId ?? newAsset.localId, + remoteId: () => existingAsset.remoteId ?? newAsset.remoteId, + width: existingAsset.width ?? newAsset.width, + height: existingAsset.height ?? newAsset.height, + createdTime: oldestCreationTime, ); } @override String toString() => """ { - "id": "$id", + "id": "${id ?? "-"}", "remoteId": "${remoteId ?? "-"}", "localId": "${localId ?? "-"}", "name": "$name", @@ -163,8 +172,7 @@ class Asset { livePhotoVideoId.hashCode; } - static int compareByRemoteId(Asset a, Asset b) => - CollectionUtil.compareToNullable(a.remoteId, b.remoteId); + static int compareByHash(Asset a, Asset b) => a.hash.compareTo(b.hash); static int compareByLocalId(Asset a, Asset b) => CollectionUtil.compareToNullable(a.localId, b.localId); diff --git a/mobile-v2/lib/domain/models/device_asset_download.model.dart b/mobile-v2/lib/domain/models/device_asset_download.model.dart new file mode 100644 index 0000000000..775e7216f5 --- /dev/null +++ b/mobile-v2/lib/domain/models/device_asset_download.model.dart @@ -0,0 +1,56 @@ +import 'dart:async'; +import 'dart:io'; + +enum DeviceAssetRequestStatus { + preparing, + downloading, + success, + failed, +} + +class DeviceAssetDownloadHandler { + DeviceAssetDownloadHandler() : stream = const Stream.empty() { + assert( + Platform.isIOS || Platform.isMacOS, + '$runtimeType should only be used on iOS or macOS.', + ); + } + + /// A stream that provides information about the download status and progress of the asset being downloaded. + Stream stream; +} + +class DeviceAssetDownloadState { + final double progress; + final DeviceAssetRequestStatus status; + + const DeviceAssetDownloadState({ + required this.progress, + required this.status, + }); + + DeviceAssetDownloadState copyWith({ + double? progress, + DeviceAssetRequestStatus? status, + }) { + return DeviceAssetDownloadState( + progress: progress ?? this.progress, + status: status ?? this.status, + ); + } + + @override + String toString() { + return 'DeviceAssetDownloadState(progress: $progress, status: $status)'; + } + + @override + bool operator ==(covariant DeviceAssetDownloadState other) { + return other.progress == progress && other.status == status; + } + + @override + int get hashCode { + return progress.hashCode ^ status.hashCode; + } +} diff --git a/mobile-v2/lib/domain/models/device_asset_hash.model.dart b/mobile-v2/lib/domain/models/device_asset_hash.model.dart new file mode 100644 index 0000000000..6d6b97230e --- /dev/null +++ b/mobile-v2/lib/domain/models/device_asset_hash.model.dart @@ -0,0 +1,57 @@ +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/utils/collection_util.dart'; + +@immutable +class DeviceAssetToHash { + final int? id; + final String localId; + final String hash; + final DateTime modifiedTime; + + const DeviceAssetToHash({ + this.id, + required this.localId, + required this.hash, + required this.modifiedTime, + }); + + @override + bool operator ==(covariant DeviceAssetToHash other) { + if (identical(this, other)) return true; + + return other.id == id && + other.localId == localId && + other.hash == hash && + other.modifiedTime == modifiedTime; + } + + @override + int get hashCode { + return id.hashCode ^ + localId.hashCode ^ + hash.hashCode ^ + modifiedTime.hashCode; + } + + DeviceAssetToHash copyWith({ + int? id, + String? localId, + String? hash, + DateTime? modifiedTime, + }) { + return DeviceAssetToHash( + id: id ?? this.id, + localId: localId ?? this.localId, + hash: hash ?? this.hash, + modifiedTime: modifiedTime ?? this.modifiedTime, + ); + } + + @override + String toString() { + return 'DeviceAssetToHash(id: ${id ?? "-"}, localId: $localId, hash: $hash, modifiedTime: $modifiedTime)'; + } + + static int compareByLocalId(DeviceAssetToHash a, DeviceAssetToHash b) => + CollectionUtil.compareToNullable(a.localId, b.localId); +} diff --git a/mobile-v2/lib/domain/models/log.model.dart b/mobile-v2/lib/domain/models/log.model.dart index c62dd2dad7..433f1a1a90 100644 --- a/mobile-v2/lib/domain/models/log.model.dart +++ b/mobile-v2/lib/domain/models/log.model.dart @@ -16,7 +16,6 @@ extension LevelExtension on Level { LogLevel toLogLevel() => switch (this) { Level.FINEST => LogLevel.verbose, Level.FINE => LogLevel.debug, - Level.INFO => LogLevel.info, Level.WARNING => LogLevel.warning, Level.SEVERE => LogLevel.error, Level.SHOUT => LogLevel.wtf, diff --git a/mobile-v2/lib/domain/models/store.model.dart b/mobile-v2/lib/domain/models/store.model.dart index 7329fe3270..a60d9fce41 100644 --- a/mobile-v2/lib/domain/models/store.model.dart +++ b/mobile-v2/lib/domain/models/store.model.dart @@ -33,35 +33,35 @@ class StoreKeyNotFoundException implements Exception { /// Key for each possible value in the `Store`. /// Also stores the converter to convert the value to and from the store and the type of value stored in the Store enum StoreKey { - serverEndpoint( + serverEndpoint._( 0, converter: StoreStringConverter(), type: String, ), - accessToken( + accessToken._( 1, converter: StoreStringConverter(), type: String, ), - currentUser( + currentUser._( 2, converter: StoreUserConverter(), type: String, ), // App settings - appTheme( + appTheme._( 1000, converter: StoreEnumConverter(AppTheme.values), type: int, ), - themeMode( + themeMode._( 1001, converter: StoreEnumConverter(ThemeMode.values), type: int, ), - darkMode(1002, converter: StoreBooleanConverter(), type: int); + darkMode._(1002, converter: StoreBooleanConverter(), type: int); - const StoreKey(this.id, {required this.converter, required this.type}); + const StoreKey._(this.id, {required this.converter, required this.type}); final int id; /// Primitive Type is also stored here to easily fetch it during runtime diff --git a/mobile-v2/lib/domain/repositories/album.repository.dart b/mobile-v2/lib/domain/repositories/album.repository.dart new file mode 100644 index 0000000000..ab2c3671ad --- /dev/null +++ b/mobile-v2/lib/domain/repositories/album.repository.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/entities/album.entity.drift.dart'; +import 'package:immich_mobile/domain/interfaces/album.interface.dart'; +import 'package:immich_mobile/domain/models/album.model.dart'; +import 'package:immich_mobile/domain/repositories/database.repository.dart'; +import 'package:immich_mobile/utils/mixins/log.mixin.dart'; + +class AlbumRepository with LogMixin implements IAlbumRepository { + final DriftDatabaseRepository _db; + + const AlbumRepository(this._db); + + @override + FutureOr upsert(Album album) async { + try { + final albumData = _toEntity(album); + final data = await _db.into(_db.album).insertReturningOrNull( + albumData, + onConflict: DoUpdate((_) => albumData, target: [_db.album.localId]), + ); + if (data != null) { + return _toModel(data); + } + } catch (e, s) { + log.e("Error while adding an album to the DB", e, s); + } + return null; + } + + @override + FutureOr> getAll({ + bool localOnly = false, + bool remoteOnly = false, + }) async { + final query = _db.album.select(); + + if (localOnly == true) { + query.where((album) => album.localId.isNotNull()); + } + + if (remoteOnly == true) { + query.where((album) => album.remoteId.isNotNull()); + } + + query.orderBy([(album) => OrderingTerm.asc(album.name)]); + return await query.map(_toModel).get(); + } + + @override + FutureOr deleteId(int id) async { + await _db.asset.deleteWhere((row) => row.id.equals(id)); + } +} + +AlbumCompanion _toEntity(Album album) { + return AlbumCompanion.insert( + id: Value.absentIfNull(album.id), + localId: Value(album.localId), + remoteId: Value(album.remoteId), + name: album.name, + modifiedTime: Value(album.modifiedTime), + thumbnailAssetId: Value(album.thumbnailAssetId), + ); +} + +Album _toModel(AlbumData album) { + return Album( + id: album.id, + localId: album.localId, + remoteId: album.remoteId, + name: album.name, + modifiedTime: album.modifiedTime, + ); +} diff --git a/mobile-v2/lib/domain/repositories/album_asset.repository.dart b/mobile-v2/lib/domain/repositories/album_asset.repository.dart new file mode 100644 index 0000000000..8eac9cf68f --- /dev/null +++ b/mobile-v2/lib/domain/repositories/album_asset.repository.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/entities/album_asset.entity.drift.dart'; +import 'package:immich_mobile/domain/interfaces/album_asset.interface.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/domain/repositories/database.repository.dart'; +import 'package:immich_mobile/domain/utils/drift_model_converters.dart'; +import 'package:immich_mobile/utils/mixins/log.mixin.dart'; + +class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository { + final DriftDatabaseRepository _db; + + const AlbumToAssetRepository(this._db); + + @override + FutureOr addAssetIds(int albumId, Iterable assetIds) async { + try { + await _db.albumToAsset.insertAll( + assetIds.map((a) => AlbumToAssetCompanion.insert( + assetId: a, + albumId: albumId, + )), + onConflict: DoNothing( + target: [_db.albumToAsset.assetId, _db.albumToAsset.albumId], + ), + ); + return true; + } catch (e, s) { + log.e("Error while adding assets to albumId - $albumId", e, s); + return false; + } + } + + @override + FutureOr> getAssetIdsOnlyInAlbum(int albumId) async { + final assetId = _db.asset.id; + final query = _db.asset.selectOnly() + ..addColumns([assetId]) + ..join([ + innerJoin( + _db.albumToAsset, + _db.albumToAsset.assetId.equalsExp(_db.asset.id) & + _db.asset.remoteId.isNull(), + useColumns: false, + ), + ]) + ..groupBy( + [assetId], + having: _db.albumToAsset.albumId.count().equals(1) & + _db.albumToAsset.albumId.max().equals(albumId), + ); + + return await query.map((row) => row.read(assetId)!).get(); + } + + @override + FutureOr> getAssetsForAlbum(int albumId) async { + final query = _db.asset.select().join([ + innerJoin( + _db.albumToAsset, + _db.albumToAsset.assetId.equalsExp(_db.asset.id) & + _db.albumToAsset.albumId.equals(albumId), + useColumns: false, + ), + ]); + + return await query + .map((row) => + DriftModelConverters.toAssetModel(row.readTable(_db.asset))) + .get(); + } + + @override + FutureOr deleteAlbumId(int albumId) async { + await _db.albumToAsset.deleteWhere((row) => row.albumId.equals(albumId)); + } +} diff --git a/mobile-v2/lib/domain/repositories/album_etag.repository.dart b/mobile-v2/lib/domain/repositories/album_etag.repository.dart new file mode 100644 index 0000000000..889d19881c --- /dev/null +++ b/mobile-v2/lib/domain/repositories/album_etag.repository.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/entities/album_etag.entity.drift.dart'; +import 'package:immich_mobile/domain/interfaces/album_etag.interface.dart'; +import 'package:immich_mobile/domain/models/album_etag.model.dart'; +import 'package:immich_mobile/domain/repositories/database.repository.dart'; +import 'package:immich_mobile/utils/mixins/log.mixin.dart'; + +class AlbumETagRepository with LogMixin implements IAlbumETagRepository { + final DriftDatabaseRepository _db; + + const AlbumETagRepository(this._db); + + @override + FutureOr upsert(AlbumETag albumETag) async { + try { + final entity = _toEntity(albumETag); + await _db.into(_db.albumETag).insert( + entity, + onConflict: + DoUpdate((_) => entity, target: [_db.albumETag.albumId]), + ); + return true; + } catch (e, s) { + log.e("Error while adding an album etag to the DB", e, s); + } + return false; + } + + @override + FutureOr get(int albumId) async { + return await _db.managers.albumETag + .filter((r) => r.albumId.id.equals(albumId)) + .map(_toModel) + .getSingleOrNull(); + } +} + +AlbumETagCompanion _toEntity(AlbumETag albumETag) { + return AlbumETagCompanion.insert( + id: Value.absentIfNull(albumETag.id), + modifiedTime: Value(albumETag.modifiedTime), + albumId: albumETag.albumId, + assetCount: Value(albumETag.assetCount), + ); +} + +AlbumETag _toModel(AlbumETagData albumETag) { + return AlbumETag( + albumId: albumETag.albumId, + assetCount: albumETag.assetCount, + modifiedTime: albumETag.modifiedTime, + id: albumETag.id, + ); +} diff --git a/mobile-v2/lib/domain/repositories/asset.repository.dart b/mobile-v2/lib/domain/repositories/asset.repository.dart index 396e0f30a1..8fc58c070c 100644 --- a/mobile-v2/lib/domain/repositories/asset.repository.dart +++ b/mobile-v2/lib/domain/repositories/asset.repository.dart @@ -5,24 +5,31 @@ import 'package:immich_mobile/domain/entities/asset.entity.drift.dart'; import 'package:immich_mobile/domain/interfaces/asset.interface.dart'; import 'package:immich_mobile/domain/models/asset.model.dart'; import 'package:immich_mobile/domain/repositories/database.repository.dart'; +import 'package:immich_mobile/domain/utils/drift_model_converters.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; -class AssetDriftRepository with LogMixin implements IAssetRepository { +class AssetRepository with LogMixin implements IAssetRepository { final DriftDatabaseRepository _db; - const AssetDriftRepository(this._db); + const AssetRepository(this._db); @override Future upsertAll(Iterable assets) async { try { - await _db.batch((batch) => batch.insertAllOnConflictUpdate( + await _db.batch((batch) { + final rows = assets.map(_toEntity); + for (final row in rows) { + batch.insert( _db.asset, - assets.map(_toEntity), - )); + row, + onConflict: DoUpdate((_) => row, target: [_db.asset.hash]), + ); + } + }); return true; } catch (e, s) { - log.e("Cannot insert remote assets into table", e, s); + log.e("Cannot insert assets into table", e, s); return false; } } @@ -33,7 +40,7 @@ class AssetDriftRepository with LogMixin implements IAssetRepository { await _db.asset.deleteAll(); return true; } catch (e, s) { - log.e("Cannot clear remote assets", e, s); + log.e("Cannot clear assets", e, s); return false; } } @@ -47,35 +54,45 @@ class AssetDriftRepository with LogMixin implements IAssetRepository { query.limit(limit, offset: offset); } - return (await query.get()).map(_toModel).toList(); + return (await query.map(DriftModelConverters.toAssetModel).get()).toList(); } @override - Future> getForLocalIds(List localIds) async { + Future> getForLocalIds(Iterable localIds) async { final query = _db.asset.select() ..where((row) => row.localId.isIn(localIds)) - ..orderBy([(asset) => OrderingTerm.asc(asset.localId)]); + ..orderBy([(asset) => OrderingTerm.asc(asset.hash)]); - return (await query.get()).map(_toModel).toList(); + return (await query.get()).map(DriftModelConverters.toAssetModel).toList(); } @override - Future> getForRemoteIds(List remoteIds) async { + Future> getForRemoteIds(Iterable remoteIds) async { final query = _db.asset.select() ..where((row) => row.remoteId.isIn(remoteIds)) - ..orderBy([(asset) => OrderingTerm.asc(asset.remoteId)]); + ..orderBy([(asset) => OrderingTerm.asc(asset.hash)]); - return (await query.get()).map(_toModel).toList(); + return (await query.get()).map(DriftModelConverters.toAssetModel).toList(); } @override - FutureOr deleteIds(List ids) async { + Future> getForHashes(Iterable hashes) async { + final query = _db.asset.select() + ..where((row) => row.hash.isIn(hashes)) + ..orderBy([(asset) => OrderingTerm.asc(asset.hash)]); + + return (await query.get()).map(DriftModelConverters.toAssetModel).toList(); + } + + @override + FutureOr deleteIds(Iterable ids) async { await _db.asset.deleteWhere((row) => row.id.isIn(ids)); } } AssetCompanion _toEntity(Asset asset) { return AssetCompanion.insert( + id: Value.absentIfNull(asset.id), localId: Value(asset.localId), remoteId: Value(asset.remoteId), name: asset.name, @@ -89,20 +106,3 @@ AssetCompanion _toEntity(Asset asset) { livePhotoVideoId: Value(asset.livePhotoVideoId), ); } - -Asset _toModel(AssetData asset) { - return Asset( - id: asset.id, - localId: asset.localId, - remoteId: asset.remoteId, - name: asset.name, - type: asset.type, - hash: asset.hash, - createdTime: asset.createdTime, - modifiedTime: asset.modifiedTime, - height: asset.height, - width: asset.width, - livePhotoVideoId: asset.livePhotoVideoId, - duration: asset.duration, - ); -} diff --git a/mobile-v2/lib/domain/repositories/database.repository.dart b/mobile-v2/lib/domain/repositories/database.repository.dart index 6676d91866..ec49d9ef56 100644 --- a/mobile-v2/lib/domain/repositories/database.repository.dart +++ b/mobile-v2/lib/domain/repositories/database.repository.dart @@ -1,18 +1,36 @@ +import 'dart:async'; + import 'package:drift/drift.dart'; // ignore: depend_on_referenced_packages import 'package:drift_dev/api/migrations.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/entities/album.entity.dart'; +import 'package:immich_mobile/domain/entities/album_asset.entity.dart'; +import 'package:immich_mobile/domain/entities/album_etag.entity.dart'; import 'package:immich_mobile/domain/entities/asset.entity.dart'; +import 'package:immich_mobile/domain/entities/device_asset_hash.entity.dart'; import 'package:immich_mobile/domain/entities/log.entity.dart'; import 'package:immich_mobile/domain/entities/store.entity.dart'; import 'package:immich_mobile/domain/entities/user.entity.dart'; +import 'package:immich_mobile/domain/interfaces/database.interface.dart'; import 'database.repository.drift.dart'; -@DriftDatabase(tables: [Logs, Store, LocalAlbum, Asset, User]) -class DriftDatabaseRepository extends $DriftDatabaseRepository { +@DriftDatabase( + tables: [ + Logs, + Store, + User, + Asset, + DeviceAssetToHash, + Album, + AlbumToAsset, + AlbumETag, + ], +) +class DriftDatabaseRepository extends $DriftDatabaseRepository + implements IDatabaseRepository { DriftDatabaseRepository([QueryExecutor? executor]) : super(executor ?? driftDatabase( @@ -37,4 +55,7 @@ class DriftDatabaseRepository extends $DriftDatabaseRepository { // ignore: no-empty-block onUpgrade: (m, from, to) async {}, ); + + @override + Future txn(Future Function() action) => transaction(action); } diff --git a/mobile-v2/lib/domain/repositories/device_album.repository.dart b/mobile-v2/lib/domain/repositories/device_album.repository.dart new file mode 100644 index 0000000000..f9ecfd98ab --- /dev/null +++ b/mobile-v2/lib/domain/repositories/device_album.repository.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:immich_mobile/domain/interfaces/device_album.interface.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/models/album.model.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/mixins/log.mixin.dart'; +import 'package:photo_manager/photo_manager.dart'; + +class DeviceAlbumRepository with LogMixin implements IDeviceAlbumRepository { + const DeviceAlbumRepository(); + + @override + Future> getAll() async { + final List assetPathEntities = + await PhotoManager.getAssetPathList( + hasAll: Platform.isIOS, + filterOption: FilterOptionGroup(containsPathModified: true), + ); + return assetPathEntities.map(_toModel).toList(); + } + + @override + Future> getAssetsForAlbum( + String albumId, { + int start = 0, + int end = 0x7fffffffffffffff, + DateTime? modifiedFrom, + DateTime? modifiedUntil, + bool orderByModificationDate = false, + }) async { + final album = await _getDeviceAlbum( + albumId, + modifiedFrom: modifiedFrom, + modifiedUntil: modifiedUntil, + orderByModificationDate: orderByModificationDate, + ); + final List assets = + await album.getAssetListRange(start: start, end: end); + return await Future.wait( + assets.map((a) async => await di().toAsset(a)), + ); + } + + Future _getDeviceAlbum( + String albumId, { + DateTime? modifiedFrom, + DateTime? modifiedUntil, + bool orderByModificationDate = false, + }) async { + return await AssetPathEntity.fromId( + albumId, + filterOption: FilterOptionGroup( + containsPathModified: true, + orders: orderByModificationDate + ? [const OrderOption(type: OrderOptionType.updateDate)] + : [], + imageOption: const FilterOption(needTitle: true), + videoOption: const FilterOption(needTitle: true), + updateTimeCond: DateTimeCond( + min: modifiedFrom ?? DateTime.utc(-271820), + max: modifiedUntil ?? DateTime.utc(275760), + ignore: modifiedFrom != null || modifiedUntil != null, + ), + ), + ); + } + + @override + Future getAssetCount(String albumId) async { + final album = await _getDeviceAlbum(albumId); + return await album.assetCountAsync; + } +} + +Album _toModel(AssetPathEntity album) { + return Album( + localId: album.id, + name: album.name, + modifiedTime: album.lastModified ?? DateTime.now(), + ); +} diff --git a/mobile-v2/lib/domain/repositories/device_asset.repository.dart b/mobile-v2/lib/domain/repositories/device_asset.repository.dart new file mode 100644 index 0000000000..c609b89d20 --- /dev/null +++ b/mobile-v2/lib/domain/repositories/device_asset.repository.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/domain/models/device_asset_download.model.dart'; +import 'package:immich_mobile/utils/constants/globals.dart'; +import 'package:immich_mobile/utils/mixins/log.mixin.dart'; +import 'package:photo_manager/photo_manager.dart' as ph; + +class DeviceAssetRepository + with LogMixin + implements IDeviceAssetRepository { + const DeviceAssetRepository(); + + @override + Future toAsset(ph.AssetEntity entity) async { + var asset = Asset( + hash: '', + name: await entity.titleAsync, + type: _toAssetType(entity.type), + createdTime: entity.createDateTime, + modifiedTime: entity.modifiedDateTime, + duration: entity.duration, + height: entity.height, + width: entity.width, + localId: entity.id, + ); + if (asset.createdTime.year == 1970) { + asset = asset.copyWith(createdTime: asset.modifiedTime); + } + return asset; + } + + @override + FutureOr getOriginalFile(String localId) async { + try { + final entity = await ph.AssetEntity.fromId(localId); + if (entity == null) { + return null; + } + + return await entity.originFile; + } catch (e, s) { + log.e("Exception while fetching file for localId - $localId", e, s); + } + return null; + } + + @override + FutureOr getThumbnail( + String assetId, { + int width = kGridThumbnailSize, + int height = kGridThumbnailSize, + int quality = kGridThumbnailQuality, + DeviceAssetDownloadHandler? downloadHandler, + }) async { + try { + final entity = await ph.AssetEntity.fromId(assetId); + if (entity == null) { + return null; + } + + ph.PMProgressHandler? progressHandler; + if (downloadHandler != null) { + progressHandler = ph.PMProgressHandler(); + downloadHandler.stream = progressHandler.stream.map(_toDownloadState); + } + + return await entity.thumbnailDataWithSize( + ph.ThumbnailSize(width, height), + quality: quality, + progressHandler: progressHandler, + ); + } catch (e, s) { + log.e("Exception while fetching thumbnail for assetId - $assetId", e, s); + } + return null; + } +} + +AssetType _toAssetType(ph.AssetType type) => switch (type) { + ph.AssetType.audio => AssetType.audio, + ph.AssetType.image => AssetType.image, + ph.AssetType.video => AssetType.video, + ph.AssetType.other => AssetType.other, + }; + +DeviceAssetDownloadState _toDownloadState(ph.PMProgressState state) { + return DeviceAssetDownloadState( + progress: state.progress, + status: switch (state.state) { + ph.PMRequestState.prepare => DeviceAssetRequestStatus.preparing, + ph.PMRequestState.loading => DeviceAssetRequestStatus.downloading, + ph.PMRequestState.success => DeviceAssetRequestStatus.success, + ph.PMRequestState.failed => DeviceAssetRequestStatus.failed, + }, + ); +} diff --git a/mobile-v2/lib/domain/repositories/device_asset_hash.repository.dart b/mobile-v2/lib/domain/repositories/device_asset_hash.repository.dart new file mode 100644 index 0000000000..d63d91a76b --- /dev/null +++ b/mobile-v2/lib/domain/repositories/device_asset_hash.repository.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/entities/device_asset_hash.entity.drift.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset_hash.interface.dart'; +import 'package:immich_mobile/domain/models/device_asset_hash.model.dart'; +import 'package:immich_mobile/domain/repositories/database.repository.dart'; +import 'package:immich_mobile/utils/mixins/log.mixin.dart'; + +class DeviceAssetToHashRepository + with LogMixin + implements IDeviceAssetToHashRepository { + final DriftDatabaseRepository _db; + + const DeviceAssetToHashRepository(this._db); + + @override + FutureOr upsertAll(Iterable assetHash) async { + try { + await _db.batch((batch) => batch.insertAllOnConflictUpdate( + _db.deviceAssetToHash, + assetHash.map(_toEntity), + )); + + return true; + } catch (e, s) { + log.e("Cannot add device assets to hash entry", e, s); + return false; + } + } + + @override + Future> getForIds(Iterable localIds) async { + return await _db.managers.deviceAssetToHash + .filter((f) => f.localId.isIn(localIds)) + .map(_toModel) + .get(); + } + + @override + FutureOr deleteIds(Iterable ids) async { + await _db.deviceAssetToHash.deleteWhere((row) => row.id.isIn(ids)); + } +} + +DeviceAssetToHashCompanion _toEntity(DeviceAssetToHash asset) { + return DeviceAssetToHashCompanion.insert( + id: Value.absentIfNull(asset.id), + localId: asset.localId, + hash: asset.hash, + modifiedTime: Value(asset.modifiedTime), + ); +} + +DeviceAssetToHash _toModel(DeviceAssetToHashData asset) { + return DeviceAssetToHash( + id: asset.id, + localId: asset.localId, + hash: asset.hash, + modifiedTime: asset.modifiedTime, + ); +} diff --git a/mobile-v2/lib/domain/repositories/log.repository.dart b/mobile-v2/lib/domain/repositories/log.repository.dart index 5d2b896714..0621d11860 100644 --- a/mobile-v2/lib/domain/repositories/log.repository.dart +++ b/mobile-v2/lib/domain/repositories/log.repository.dart @@ -7,10 +7,10 @@ import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/repositories/database.repository.dart'; -class LogDriftRepository implements ILogRepository { +class LogRepository implements ILogRepository { final DriftDatabaseRepository _db; - const LogDriftRepository(this._db); + const LogRepository(this._db); @override Future> getAll() async { @@ -32,14 +32,7 @@ class LogDriftRepository implements ILogRepository { @override FutureOr create(LogMessage log) async { try { - await _db.into(_db.logs).insert(LogsCompanion.insert( - content: log.content, - level: log.level, - createdAt: Value(log.createdAt), - error: Value(log.error), - logger: Value(log.logger), - stack: Value(log.stack), - )); + await _db.into(_db.logs).insert(_toEntity(log)); return true; } catch (e) { debugPrint("Error while adding a log to the DB - $e"); @@ -48,20 +41,10 @@ class LogDriftRepository implements ILogRepository { } @override - FutureOr createAll(List logs) async { + FutureOr createAll(Iterable logs) async { try { await _db.batch((b) { - b.insertAll( - _db.logs, - logs.map((log) => LogsCompanion.insert( - content: log.content, - level: log.level, - createdAt: Value(log.createdAt), - error: Value(log.error), - logger: Value(log.logger), - stack: Value(log.stack), - )), - ); + b.insertAll(_db.logs, logs.map(_toEntity)); }); return true; } catch (e) { @@ -82,6 +65,17 @@ class LogDriftRepository implements ILogRepository { } } +LogsCompanion _toEntity(LogMessage log) { + return LogsCompanion.insert( + content: log.content, + level: log.level, + createdAt: Value(log.createdAt), + error: Value(log.error), + logger: Value(log.logger), + stack: Value(log.stack), + ); +} + LogMessage _toModel(Log log) { return LogMessage( content: log.content, diff --git a/mobile-v2/lib/domain/repositories/renderlist.repository.dart b/mobile-v2/lib/domain/repositories/renderlist.repository.dart index 347984a825..a41dff53b2 100644 --- a/mobile-v2/lib/domain/repositories/renderlist.repository.dart +++ b/mobile-v2/lib/domain/repositories/renderlist.repository.dart @@ -6,10 +6,10 @@ import 'package:immich_mobile/domain/repositories/database.repository.dart'; import 'package:immich_mobile/utils/extensions/drift.extension.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; -class RenderListDriftRepository with LogMixin implements IRenderListRepository { +class RenderListRepository with LogMixin implements IRenderListRepository { final DriftDatabaseRepository _db; - const RenderListDriftRepository(this._db); + const RenderListRepository(this._db); @override Stream watchAll() { diff --git a/mobile-v2/lib/domain/repositories/store.repository.dart b/mobile-v2/lib/domain/repositories/store.repository.dart index 27d0a5bb1c..117b6a45f2 100644 --- a/mobile-v2/lib/domain/repositories/store.repository.dart +++ b/mobile-v2/lib/domain/repositories/store.repository.dart @@ -7,10 +7,10 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/repositories/database.repository.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; -class StoreDriftRepository with LogMixin implements IStoreRepository { +class StoreRepository with LogMixin implements IStoreRepository { final DriftDatabaseRepository _db; - const StoreDriftRepository(this._db); + const StoreRepository(this._db); @override FutureOr tryGet(StoreKey key) async { diff --git a/mobile-v2/lib/domain/repositories/user.repository.dart b/mobile-v2/lib/domain/repositories/user.repository.dart index 3eef6ecb80..07bac4b3ba 100644 --- a/mobile-v2/lib/domain/repositories/user.repository.dart +++ b/mobile-v2/lib/domain/repositories/user.repository.dart @@ -7,10 +7,10 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/repositories/database.repository.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; -class UserDriftRepository with LogMixin implements IUserRepository { +class UserRepository with LogMixin implements IUserRepository { final DriftDatabaseRepository _db; - const UserDriftRepository(this._db); + const UserRepository(this._db); @override FutureOr getForId(String userId) async { diff --git a/mobile-v2/lib/domain/services/album_sync.service.dart b/mobile-v2/lib/domain/services/album_sync.service.dart new file mode 100644 index 0000000000..b4937666dd --- /dev/null +++ b/mobile-v2/lib/domain/services/album_sync.service.dart @@ -0,0 +1,131 @@ +import 'package:immich_mobile/domain/interfaces/album.interface.dart'; +import 'package:immich_mobile/domain/interfaces/album_asset.interface.dart'; +import 'package:immich_mobile/domain/interfaces/album_etag.interface.dart'; +import 'package:immich_mobile/domain/interfaces/asset.interface.dart'; +import 'package:immich_mobile/domain/interfaces/database.interface.dart'; +import 'package:immich_mobile/domain/interfaces/device_album.interface.dart'; +import 'package:immich_mobile/domain/models/album.model.dart'; +import 'package:immich_mobile/domain/models/album_etag.model.dart'; +import 'package:immich_mobile/domain/services/asset_sync.service.dart'; +import 'package:immich_mobile/domain/services/hash.service.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/collection_util.dart'; +import 'package:immich_mobile/utils/isolate_helper.dart'; +import 'package:immich_mobile/utils/mixins/log.mixin.dart'; + +class AlbumSyncService with LogMixin { + const AlbumSyncService(); + + Future performFullDeviceSyncIsolate() async { + return await IsolateHelper.run(performFullDeviceSync); + } + + Future performFullDeviceSync() async { + try { + final deviceAlbums = await di().getAll(); + final dbAlbums = await di().getAll(localOnly: true); + final hasChange = await CollectionUtil.diffLists( + dbAlbums, + deviceAlbums, + compare: Album.compareByLocalId, + both: _syncDeviceAlbum, + // Album is in DB but not anymore in device. Remove album and album specific assets + onlyFirst: _removeDeviceAlbum, + onlySecond: _addDeviceAlbum, + ); + + return hasChange; + } catch (e, s) { + log.e("Error performing full device sync", e, s); + } + return false; + } + + Future _syncDeviceAlbum( + Album dbAlbum, + Album deviceAlbum, { + DateTime? modifiedUntil, + }) async { + assert(dbAlbum.id != null, "Album ID from DB is null"); + final albumEtag = + await di().get(dbAlbum.id!) ?? AlbumETag.empty(); + final assetCountInDevice = + await di().getAssetCount(deviceAlbum.localId!); + + final albumNotUpdated = deviceAlbum.name == dbAlbum.name && + dbAlbum.modifiedTime.isAtSameMomentAs(deviceAlbum.modifiedTime) && + assetCountInDevice == albumEtag.assetCount; + if (albumNotUpdated) { + log.i("Device Album ${deviceAlbum.name} not updated. Skipping sync."); + return false; + } + + await _addDeviceAlbum(dbAlbum, modifiedUntil: modifiedUntil); + return true; + } + + Future _addDeviceAlbum(Album album, {DateTime? modifiedUntil}) async { + try { + final albumId = (await di().upsert(album))?.id; + // break fast if we cannot add an album + if (albumId == null) { + log.d("Failed creating device album. Skipped assets from album"); + return; + } + + final assets = await di().getHashedAssetsForAlbum( + album.localId!, + modifiedUntil: modifiedUntil, + ); + + await di().txn(() async { + final albumAssetsInDB = + await di().getAssetsForAlbum(albumId); + + await di().upsertAssetsToDb( + assets, + albumAssetsInDB, + isRemoteSync: false, + ); + + // This is needed to get the updated assets for device album with valid db id field + final albumAssets = await di() + .getForLocalIds(assets.map((a) => a.localId!)); + + await di().addAssetIds( + albumId, + albumAssets.map((a) => a.id!), + ); + await di().upsert( + album.copyWith(thumbnailAssetId: albumAssets.firstOrNull?.id), + ); + + // Update ETag + final albumETag = AlbumETag( + albumId: albumId, + assetCount: assets.length, + modifiedTime: album.modifiedTime, + ); + await di().upsert(albumETag); + }); + } catch (e, s) { + log.w("Error while adding device album", e, s); + } + } + + Future _removeDeviceAlbum(Album album) async { + assert(album.id != null, "Album ID from DB is null"); + final albumId = album.id!; + try { + await di().txn(() async { + final toRemove = + await di().getAssetIdsOnlyInAlbum(albumId); + await di().deleteId(albumId); + await di().deleteAlbumId(albumId); + await di().deleteIds(toRemove); + }); + } catch (e, s) { + log.w("Error while removing device album", e, s); + } + } +} diff --git a/mobile-v2/lib/domain/services/asset_sync.service.dart b/mobile-v2/lib/domain/services/asset_sync.service.dart index 5886fca4af..07905088a5 100644 --- a/mobile-v2/lib/domain/services/asset_sync.service.dart +++ b/mobile-v2/lib/domain/services/asset_sync.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:immich_mobile/domain/interfaces/asset.interface.dart'; +import 'package:immich_mobile/domain/interfaces/database.interface.dart'; import 'package:immich_mobile/domain/models/asset.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/service_locator.dart'; @@ -9,76 +10,87 @@ import 'package:immich_mobile/utils/collection_util.dart'; import 'package:immich_mobile/utils/constants/globals.dart'; import 'package:immich_mobile/utils/immich_api_client.dart'; import 'package:immich_mobile/utils/isolate_helper.dart'; -import 'package:immich_mobile/utils/log_manager.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; import 'package:openapi/api.dart'; class AssetSyncService with LogMixin { const AssetSyncService(); - Future performFullRemoteSyncForUser( + Future performFullRemoteSyncIsolate( User user, { DateTime? updatedUtil, int? limit, }) async { return await IsolateHelper.run(() async { - try { - final logger = LogManager.I.get("SyncService "); - final syncClient = di().getSyncApi(); - - final chunkSize = limit ?? kFullSyncChunkSize; - final updatedTill = updatedUtil ?? DateTime.now().toUtc(); - updatedUtil ??= DateTime.now().toUtc(); - String? lastAssetId; - - while (true) { - logger.d( - "Requesting more chunks from lastId - ${lastAssetId ?? ""}", - ); - - final assets = await syncClient.getFullSyncForUser(AssetFullSyncDto( - limit: chunkSize, - updatedUntil: updatedTill, - lastId: lastAssetId, - userId: user.id, - )); - if (assets == null) { - break; - } - - final assetsFromServer = - assets.map(Asset.remote).sorted(Asset.compareByRemoteId); - - final assetsInDb = await di().getForRemoteIds( - assetsFromServer.map((a) => a.remoteId!).toList(), - ); - - await _syncAssetsToDb( - assetsFromServer, - assetsInDb, - Asset.compareByRemoteId, - isRemoteSync: true, - ); - - lastAssetId = assets.lastOrNull?.id; - if (assets.length != chunkSize) break; - } - - return true; - } catch (e, s) { - log.e("Error performing full sync for user - ${user.name}", e, s); - } - return false; + return await performFullRemoteSync( + user, + updatedUtil: updatedUtil, + limit: limit, + ); }); } - Future _syncAssetsToDb( - List newAssets, - List existingAssets, - Comparator compare, { - bool? isRemoteSync, + Future performFullRemoteSync( + User user, { + DateTime? updatedUtil, + int? limit, }) async { - final (toAdd, toUpdate, assetsToRemove) = _diffAssets( + try { + final syncClient = di().getSyncApi(); + final db = di(); + final assetRepo = di(); + + final chunkSize = limit ?? kFullSyncChunkSize; + final updatedTill = updatedUtil ?? DateTime.now().toUtc(); + updatedUtil ??= DateTime.now().toUtc(); + String? lastAssetId; + + while (true) { + log.d( + "Requesting more chunks from lastId - ${lastAssetId ?? ""}", + ); + + final assets = await syncClient.getFullSyncForUser(AssetFullSyncDto( + limit: chunkSize, + updatedUntil: updatedTill, + lastId: lastAssetId, + userId: user.id, + )); + if (assets == null) { + break; + } + + final assetsFromServer = assets.map(Asset.remote).toList(); + + await db.txn(() async { + final assetsInDb = + await assetRepo.getForHashes(assetsFromServer.map((a) => a.hash)); + + await upsertAssetsToDb( + assetsFromServer, + assetsInDb, + isRemoteSync: true, + ); + }); + + lastAssetId = assets.lastOrNull?.id; + if (assets.length != chunkSize) break; + } + + return true; + } catch (e, s) { + log.e("Error performing full remote sync for user - ${user.name}", e, s); + } + return false; + } + + Future upsertAssetsToDb( + List newAssets, + List existingAssets, { + bool? isRemoteSync, + Comparator compare = Asset.compareByHash, + }) async { + final (toAdd, toUpdate, toRemove) = await _diffAssets( newAssets, existingAssets, compare: compare, @@ -88,37 +100,36 @@ class AssetSyncService with LogMixin { final assetsToAdd = toAdd.followedBy(toUpdate); await di().upsertAll(assetsToAdd); - await di() - .deleteIds(assetsToRemove.map((a) => a.id).toList()); + await di().deleteIds(toRemove.map((a) => a.id!).toList()); } /// Returns a triple (toAdd, toUpdate, toRemove) - (List, List, List) _diffAssets( + FutureOr<(List, List, List)> _diffAssets( List newAssets, List inDb, { bool? isRemoteSync, - required Comparator compare, - }) { + Comparator compare = Asset.compareByHash, + }) async { // fast paths for trivial cases: reduces memory usage during initial sync etc. if (newAssets.isEmpty && inDb.isEmpty) { - return const ([], [], []); + return const ([], [], []); } else if (newAssets.isEmpty && isRemoteSync == null) { // remove all from database - return (const [], const [], inDb); + return (const [], const [], inDb); } else if (inDb.isEmpty) { // add all assets - return (newAssets, const [], const []); + return (newAssets, const [], const []); } final List toAdd = []; final List toUpdate = []; final List toRemove = []; - CollectionUtil.diffSortedLists( + await CollectionUtil.diffLists( inDb, newAssets, compare: compare, both: (Asset a, Asset b) { - if (a == b) { + if (a != b) { toUpdate.add(a.merge(b)); return true; } @@ -140,7 +151,7 @@ class AssetSyncService with LogMixin { toRemove.add(a); } }, - // Only in remote (new asset) + // Only in new assets onlySecond: (Asset b) => toAdd.add(b), ); return (toAdd, toUpdate, toRemove); diff --git a/mobile-v2/lib/domain/services/hash.service.dart b/mobile-v2/lib/domain/services/hash.service.dart new file mode 100644 index 0000000000..c84c99640a --- /dev/null +++ b/mobile-v2/lib/domain/services/hash.service.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/interfaces/device_album.interface.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset_hash.interface.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/domain/models/device_asset_hash.model.dart'; +import 'package:immich_mobile/platform/messages.g.dart'; +import 'package:immich_mobile/utils/constants/globals.dart'; +import 'package:immich_mobile/utils/extensions/file.extension.dart'; +import 'package:immich_mobile/utils/mixins/log.mixin.dart'; + +class HashService with LogMixin { + final ImHostService _hostService; + final IDeviceAssetRepository _deviceAssetRepository; + final IDeviceAlbumRepository _deviceAlbumRepository; + final IDeviceAssetToHashRepository _assetHashRepository; + + const HashService({ + required ImHostService hostService, + required IDeviceAssetRepository deviceAssetRepo, + required IDeviceAlbumRepository deviceAlbumRepo, + required IDeviceAssetToHashRepository assetToHashRepo, + }) : _hostService = hostService, + _deviceAssetRepository = deviceAssetRepo, + _deviceAlbumRepository = deviceAlbumRepo, + _assetHashRepository = assetToHashRepo; + + Future> getHashedAssetsForAlbum( + String albumId, { + DateTime? modifiedUntil, + }) async { + final assets = await _deviceAlbumRepository.getAssetsForAlbum( + albumId, + modifiedUntil: modifiedUntil, + ); + assets.sort(Asset.compareByLocalId); + + final assetIds = assets.map((a) => a.localId!); + final hashesInDB = await _assetHashRepository.getForIds(assetIds); + hashesInDB.sort(DeviceAssetToHash.compareByLocalId); + + final hashedAssets = []; + final orphanedHashes = []; + int bytesToBeProcessed = 0; + final filesToBeCleaned = []; + final toBeHashed = <_AssetPath>[]; + + for (final asset in assets) { + if (hashesInDB.isNotEmpty && hashesInDB.first.localId == asset.localId) { + final hashed = hashesInDB.removeAt(0); + if (hashed.modifiedTime.isAtSameMomentAs(asset.modifiedTime)) { + hashedAssets.add(asset.copyWith(hash: hashed.hash)); + continue; + } + // localID is matching, but the asset is modified. Discard the DeviceAssetToHash row + orphanedHashes.add(hashed); + } + + final file = await _deviceAssetRepository.getOriginalFile(asset.localId!); + if (file == null) { + log.w("Cannot obtain file for localId ${asset.localId!}. Skipping"); + continue; + } + filesToBeCleaned.add(file); + + bytesToBeProcessed += await file.length(); + toBeHashed.add(_AssetPath(asset: asset, path: file.path)); + + if (toBeHashed.length == kHashAssetsFileLimit || + bytesToBeProcessed >= kHashAssetsSizeLimit) { + hashedAssets.addAll(await _processAssetBatch(toBeHashed)); + // Clear file cache + await Future.wait(filesToBeCleaned.map((f) => f.deleteDarwinCache())); + toBeHashed.clear(); + filesToBeCleaned.clear(); + bytesToBeProcessed = 0; + } + } + + if (toBeHashed.isNotEmpty) { + hashedAssets.addAll(await _processAssetBatch(toBeHashed)); + // Clear file cache + await Future.wait(filesToBeCleaned.map((f) => f.deleteDarwinCache())); + } + + assert(hashesInDB.isEmpty, "All hashes should be processed at this point"); + _assetHashRepository.deleteIds(orphanedHashes.map((e) => e.id!).toList()); + + return hashedAssets; + } + + /// Processes a batch of files and returns a list of successfully hashed assets after saving + /// them in [DeviceAssetToHash] for future retrieval + Future> _processAssetBatch(List<_AssetPath> toBeHashed) async { + final hashes = await _hashFiles(toBeHashed.map((e) => e.path).toList()); + assert(hashes.length == toBeHashed.length, + "Number of Hashes returned from platform should be the same as the input"); + + final hashedAssets = []; + + for (final (index, hash) in hashes.indexed) { + // ignore: avoid-unsafe-collection-methods + final asset = toBeHashed.elementAt(index).asset; + if (hash?.length == 20) { + hashedAssets.add(asset.copyWith(hash: base64.encode(hash!))); + } else { + log.w("Failed to hash file ${asset.localId ?? ''}, skipping"); + } + } + + // Store the cache for future retrieval + _assetHashRepository.upsertAll(hashedAssets.map((a) => DeviceAssetToHash( + localId: a.localId!, + hash: a.hash, + modifiedTime: a.modifiedTime, + ))); + + log.v("Hashed ${hashedAssets.length}/${toBeHashed.length} assets"); + return hashedAssets; + } + + /// Hashes the given files and returns a list of the same length. + /// Files that could not be hashed will have a `null` value + Future> _hashFiles(List paths) async { + try { + final hashes = await _hostService.digestFiles(paths); + return hashes; + } catch (e, s) { + log.e("Error occured while hashing assets", e, s); + } + + return paths.map((p) => null).toList(); + } +} + +class _AssetPath { + final Asset asset; + final String path; + + const _AssetPath({required this.asset, required this.path}); + + _AssetPath copyWith({Asset? asset, String? path}) { + return _AssetPath(asset: asset ?? this.asset, path: path ?? this.path); + } +} diff --git a/mobile-v2/lib/domain/services/login.service.dart b/mobile-v2/lib/domain/services/login.service.dart index 38e5af63b2..6dbdfe1365 100644 --- a/mobile-v2/lib/domain/services/login.service.dart +++ b/mobile-v2/lib/domain/services/login.service.dart @@ -15,15 +15,15 @@ import 'package:openapi/api.dart'; class LoginService with LogMixin { const LoginService(); - Future isEndpointAvailable(Uri uri, {ImmichApiClient? client}) async { + Future isEndpointAvailable(Uri uri, {ImApiClient? client}) async { String baseUrl = uri.toString(); if (!baseUrl.endsWith('/api')) { baseUrl += '/api'; } - final serverAPI = client?.getServerApi() ?? - ImmichApiClient(endpoint: baseUrl).getServerApi(); + final serverAPI = + client?.getServerApi() ?? ImApiClient(endpoint: baseUrl).getServerApi(); try { await serverAPI.pingServer(); } catch (e) { @@ -35,7 +35,7 @@ class LoginService with LogMixin { Future resolveEndpoint(Uri uri, {Client? client}) async { String baseUrl = uri.toString(); - final d = client ?? ImmichApiClient(endpoint: baseUrl).client; + final d = client ?? ImApiClient(endpoint: baseUrl).client; try { // Check for well-known endpoint @@ -62,7 +62,7 @@ class LoginService with LogMixin { Future passwordLogin(String email, String password) async { try { final loginResponse = - await di().getAuthenticationApi().login( + await di().getAuthenticationApi().login( LoginCredentialDto(email: email, password: password), ); @@ -76,7 +76,7 @@ class LoginService with LogMixin { Future oAuthLogin() async { const String oAuthCallbackSchema = 'app.immich'; - final oAuthApi = di().getOAuthApi(); + final oAuthApi = di().getOAuthApi(); try { final oAuthUrl = await oAuthApi.startOAuth( @@ -125,7 +125,7 @@ class LoginService with LogMixin { } /// Set token to interceptor - await di().init(accessToken: accessToken); + await di().init(accessToken: accessToken); final user = await di().getMyUser().timeout( const Duration(seconds: 10), diff --git a/mobile-v2/lib/domain/utils/drift_model_converters.dart b/mobile-v2/lib/domain/utils/drift_model_converters.dart new file mode 100644 index 0000000000..3b9f9d08c5 --- /dev/null +++ b/mobile-v2/lib/domain/utils/drift_model_converters.dart @@ -0,0 +1,21 @@ +import 'package:immich_mobile/domain/entities/asset.entity.drift.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; + +class DriftModelConverters { + static Asset toAssetModel(AssetData asset) { + return Asset( + id: asset.id, + localId: asset.localId, + remoteId: asset.remoteId, + name: asset.name, + type: asset.type, + hash: asset.hash, + createdTime: asset.createdTime, + modifiedTime: asset.modifiedTime, + height: asset.height, + width: asset.width, + livePhotoVideoId: asset.livePhotoVideoId, + duration: asset.duration, + ); + } +} diff --git a/mobile-v2/lib/main.dart b/mobile-v2/lib/main.dart index e7cdca3eff..8fe8398ff8 100644 --- a/mobile-v2/lib/main.dart +++ b/mobile-v2/lib/main.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/immich_app.dart'; import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/utils/log_manager.dart'; +import 'package:photo_manager/photo_manager.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -13,6 +14,8 @@ void main() { LogManager.setGlobalErrorCallbacks(); // Init localization LocaleSettings.useDeviceLocale(); + // Clear photo_manager cache + PhotoManager.clearFileCache(); runApp(const ImApp()); } diff --git a/mobile-v2/lib/platform/messages.dart b/mobile-v2/lib/platform/messages.dart index 111274990c..8e55c1f929 100644 --- a/mobile-v2/lib/platform/messages.dart +++ b/mobile-v2/lib/platform/messages.dart @@ -11,7 +11,7 @@ import 'package:pigeon/pigeon.dart'; swiftOptions: SwiftOptions(), )) @HostApi() -abstract class ImmichHostService { +abstract class ImHostService { @async - List? digestFiles(List paths); + List digestFiles(List paths); } diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart index 886c118a4f..3797328d6a 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart @@ -13,7 +13,7 @@ typedef RenderListAssetProvider = FutureOr> Function({ int? limit, }); -class ImmichAssetGridCubit extends Cubit { +class AssetGridCubit extends Cubit { final Stream _renderStream; final RenderListAssetProvider _assetProvider; late final StreamSubscription _renderListSubscription; @@ -24,7 +24,7 @@ class ImmichAssetGridCubit extends Cubit { /// assets cache loaded from DB with offset [_bufOffset] List _buf = []; - ImmichAssetGridCubit({ + AssetGridCubit({ required Stream renderStream, required RenderListAssetProvider assetProvider, }) : _renderStream = renderStream, diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart index 416ee6bc53..48ae1f3fff 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart @@ -6,14 +6,13 @@ import 'package:immich_mobile/domain/models/render_list_element.model.dart'; import 'package:immich_mobile/presentation/components/grid/draggable_scrollbar.dart'; import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.state.dart'; import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart'; +import 'package:immich_mobile/presentation/components/image/immich_thumbnail.widget.dart'; import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart'; import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; -import 'package:immich_mobile/utils/extensions/color.extension.dart'; import 'package:intl/intl.dart'; import 'package:material_symbols_icons/symbols.dart'; part 'immich_asset_grid_header.widget.dart'; -part 'immich_grid_asset_placeholder.widget.dart'; class ImAssetGrid extends StatefulWidget { const ImAssetGrid({super.key}); @@ -56,8 +55,7 @@ class _ImAssetGridState extends State { } @override - Widget build(BuildContext context) => - BlocBuilder( + Widget build(BuildContext context) => BlocBuilder( builder: (_, renderList) { final elements = renderList.elements; final grid = FlutterListView( @@ -72,7 +70,7 @@ class _ImAssetGridState extends State { _MonthHeader(text: section.header), RenderListDayHeaderElement() => Text(section.header), RenderListAssetElement() => FutureBuilder( - future: context.read().loadAssets( + future: context.read().loadAssets( section.assetOffset, section.assetCount, ), @@ -83,6 +81,7 @@ class _ImAssetGridState extends State { shrinkWrap: true, addAutomaticKeepAlives: false, cacheExtent: 100, + padding: const EdgeInsets.all(0), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, @@ -97,8 +96,8 @@ class _ImAssetGridState extends State { dimension: 200, // Show Placeholder when drag scrolled child: asset == null || _isDragScrolling - ? const _ImImagePlaceholder() - : ImImage(asset), + ? const ImImagePlaceholder() + : ImThumbnail(asset), ); }, itemCount: section.assetCount, diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart index 8b6c10b325..5285b235b2 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart @@ -9,7 +9,12 @@ class _HeaderText extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(top: 32.0, left: 16.0, right: 24.0), + padding: const EdgeInsets.only( + top: 32.0, + left: 16.0, + right: 24.0, + bottom: 16.0, + ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ diff --git a/mobile-v2/lib/presentation/components/grid/immich_grid_asset_placeholder.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_grid_asset_placeholder.widget.dart deleted file mode 100644 index 4138d9c873..0000000000 --- a/mobile-v2/lib/presentation/components/grid/immich_grid_asset_placeholder.widget.dart +++ /dev/null @@ -1,25 +0,0 @@ -part of 'immich_asset_grid.widget.dart'; - -class _ImImagePlaceholder extends StatelessWidget { - const _ImImagePlaceholder(); - - @override - Widget build(BuildContext context) { - var gradientColors = [ - context.colorScheme.surfaceContainer, - context.colorScheme.surfaceContainer.darken(amount: .1), - ]; - - return Container( - width: 200, - height: 200, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: gradientColors, - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ); - } -} diff --git a/mobile-v2/lib/presentation/components/image/cache/cache_manager.dart b/mobile-v2/lib/presentation/components/image/cache/cache_manager.dart new file mode 100644 index 0000000000..b414344d5d --- /dev/null +++ b/mobile-v2/lib/presentation/components/image/cache/cache_manager.dart @@ -0,0 +1,40 @@ +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/utils/constants/globals.dart'; + +/// The cache manager for thumbnail images [ImRemoteThumbnailProvider] +class ImRemoteThumbnailCacheManager extends CacheManager { + static final ImRemoteThumbnailCacheManager _instance = + ImRemoteThumbnailCacheManager._(); + + factory ImRemoteThumbnailCacheManager() { + return _instance; + } + + ImRemoteThumbnailCacheManager._() + : super( + Config( + kCacheThumbnailsKey, + maxNrOfCacheObjects: kCacheMaxNrOfThumbnails, + stalePeriod: const Duration(days: kCacheStalePeriod), + ), + ); +} + +/// The cache manager for full size images [ImRemoteImageProvider] +class ImRemoteImageCacheManager extends CacheManager { + static final ImRemoteImageCacheManager _instance = + ImRemoteImageCacheManager._(); + + factory ImRemoteImageCacheManager() { + return _instance; + } + + ImRemoteImageCacheManager._() + : super( + Config( + kCacheFullImagesKey, + maxNrOfCacheObjects: kCacheMaxNrOfFullImages, + stalePeriod: const Duration(days: kCacheStalePeriod), + ), + ); +} diff --git a/mobile-v2/lib/presentation/components/image/cache/image_loader.dart b/mobile-v2/lib/presentation/components/image/cache/image_loader.dart new file mode 100644 index 0000000000..3720ad7338 --- /dev/null +++ b/mobile-v2/lib/presentation/components/image/cache/image_loader.dart @@ -0,0 +1,55 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/immich_api_client.dart'; + +/// An exception for the [ImageLoader] and the Immich image providers +class ImageLoadingException implements Exception { + final String message; + const ImageLoadingException(this.message); + + @override + String toString() => 'ImageLoadingException: $message'; +} + +/// Loads the codec from the URI and sends the events to the [chunkEvents] stream +/// +/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart) +/// for this wonderful implementation of their image loader +class ImageLoader { + static Future loadImageFromCache( + String uri, { + required CacheManager cache, + required ImageDecoderCallback decode, + StreamController? chunkEvents, + }) async { + final stream = cache.getFileStream( + uri, + withProgress: chunkEvents != null, + headers: di().headers, + ); + + await for (final result in stream) { + if (result is DownloadProgress) { + // We are downloading the file, so update the [chunkEvents] + chunkEvents?.add( + ImageChunkEvent( + cumulativeBytesLoaded: result.downloaded, + expectedTotalBytes: result.totalSize, + ), + ); + } else if (result is FileInfo) { + // We have the file + final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path); + final decoded = await decode(buffer); + return decoded; + } + } + + // If we get here, the image failed to load from the cache stream + throw const ImageLoadingException('Could not load image from stream'); + } +} diff --git a/mobile-v2/lib/presentation/components/image/immich_image.widget.dart b/mobile-v2/lib/presentation/components/image/immich_image.widget.dart index 515c74150e..e9fc73f59c 100644 --- a/mobile-v2/lib/presentation/components/image/immich_image.widget.dart +++ b/mobile-v2/lib/presentation/components/image/immich_image.widget.dart @@ -1,48 +1,89 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter/services.dart'; import 'package:immich_mobile/domain/models/asset.model.dart'; -import 'package:immich_mobile/service_locator.dart'; -import 'package:immich_mobile/utils/immich_api_client.dart'; -import 'package:immich_mobile/utils/immich_image_url_helper.dart'; -import 'package:material_symbols_icons/symbols.dart'; +import 'package:immich_mobile/presentation/components/image/provider/immich_local_image_provider.dart'; +import 'package:immich_mobile/presentation/components/image/provider/immich_remote_image_provider.dart'; +import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; +import 'package:octo_image/octo_image.dart'; +class ImImagePlaceholder extends StatelessWidget { + const ImImagePlaceholder({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: 200, + height: 200, + color: context.colorScheme.surfaceContainerHighest, + ); + } +} + +// ignore: prefer-single-widget-per-file class ImImage extends StatelessWidget { final Asset asset; final double? width; final double? height; + final Widget placeholder; - const ImImage(this.asset, {this.width, this.height, super.key}); + const ImImage( + this.asset, { + this.width, + this.height, + this.placeholder = const ImImagePlaceholder(), + super.key, + }); + + // Helper function to return the image provider for the asset + // either by using the asset ID or the asset itself + /// [asset] is the Asset to request, or else use [assetId] to get a remote + /// image provider + /// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail + /// The size of the square thumbnail to request. Ignored if isThumbnail + /// is not true + static ImageProvider imageProvider({Asset? asset, String? assetId}) { + if (asset == null && assetId == null) { + throw Exception('Must supply either asset or assetId'); + } + + if (asset == null) { + return ImRemoteImageProvider(assetId: assetId!); + } + + // Whether to use the local asset image provider or a remote one + final useLocal = !asset.isRemote || asset.isLocal; + + if (useLocal) { + return ImLocalImageProvider(asset: asset); + } + + return ImRemoteImageProvider(assetId: asset.remoteId!); + } @override Widget build(BuildContext context) { - return CachedNetworkImage( - imageUrl: ImImageUrlHelper.getThumbnailUrl(asset), - httpHeaders: di().headers, - cacheKey: ImImageUrlHelper.getThumbnailUrl(asset), + return OctoImage( + fadeInDuration: const Duration(milliseconds: 0), + fadeOutDuration: const Duration(milliseconds: 200), + placeholderBuilder: (_) => placeholder, + image: ImImage.imageProvider(asset: asset), width: width, height: height, - // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and - // maxHeightDiskCache = null allows to simply store the webp thumbnail - // from the server and use it for all rendered thumbnail sizes fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 250), - progressIndicatorBuilder: (_, url, downloadProgress) { - // Show loading if desired - return const SizedBox.square( - dimension: 250, - child: DecoratedBox(decoration: BoxDecoration(color: Colors.grey)), - ); - }, - errorWidget: (_, url, error) { - if (error is HttpExceptionWithStatus && - error.statusCode >= 400 && - error.statusCode < 500) { - CachedNetworkImage.evictFromCache(url); + errorBuilder: (_, error, stackTrace) { + if (error is PlatformException && + error.code == "The asset not found!") { + debugPrint( + "Asset ${asset.localId ?? asset.id ?? "-"} does not exist anymore on device!", + ); + } else { + debugPrint( + "Error getting thumb for assetId=${asset.localId ?? asset.id ?? "-"}: $error", + ); } return Icon( - Symbols.image_not_supported_rounded, - color: Theme.of(context).primaryColor, + Icons.image_not_supported_outlined, + color: context.colorScheme.primary, ); }, ); diff --git a/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart b/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart index 37e3675652..bff9baf4e1 100644 --- a/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart +++ b/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart @@ -27,6 +27,7 @@ class ImLogo extends StatelessWidget { } } +// ignore: prefer-single-widget-per-file class ImLogoText extends StatelessWidget { const ImLogoText({ super.key, diff --git a/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart b/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart new file mode 100644 index 0000000000..93cc2c1a13 --- /dev/null +++ b/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +IconData _getStorageIcon(Asset asset) { + if (asset.isMerged) { + return Symbols.cloud_done_rounded; + } else if (asset.isRemote) { + return Symbols.cloud_rounded; + } + return Symbols.cloud_off_rounded; +} + +class ImThumbnail extends StatelessWidget { + final Asset asset; + final double? width; + final double? height; + + const ImThumbnail(this.asset, {this.width, this.height, super.key}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: ImImage(asset, width: width, height: height)), + _PadAlignedIcon( + alignment: Alignment.bottomRight, + icon: _getStorageIcon(asset), + ), + if (!asset.isImage) + const _PadAlignedIcon( + alignment: Alignment.topLeft, + icon: Symbols.play_circle_rounded, + filled: true, + ), + ], + ); + } +} + +class _PadAlignedIcon extends StatelessWidget { + final Alignment alignment; + final IconData icon; + final bool? filled; + + const _PadAlignedIcon({ + required this.alignment, + required this.icon, + this.filled, + }); + + double _calculateLeft(Alignment align) { + return align.x == -1 ? 5 : 0; + } + + double _calculateTop(Alignment align) { + return align.y == -1 ? 4 : 0; + } + + double _calculateRight(Alignment align) { + return align.x == 1 ? 5 : 0; + } + + double _calculateBottom(Alignment align) { + return align.y == 1 ? 4 : 0; + } + + @override + Widget build(BuildContext context) { + return Positioned( + left: _calculateLeft(alignment), + top: _calculateTop(alignment), + right: _calculateRight(alignment), + bottom: _calculateBottom(alignment), + child: Align( + alignment: alignment, + child: Icon( + icon, + color: Colors.white, + size: 20, + fill: (filled != null && filled!) ? 1 : null, + ), + ), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/image/provider/immich_local_image_provider.dart b/mobile-v2/lib/presentation/components/image/provider/immich_local_image_provider.dart new file mode 100644 index 0000000000..946321cc39 --- /dev/null +++ b/mobile-v2/lib/presentation/components/image/provider/immich_local_image_provider.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/extensions/file.extension.dart'; + +/// The local image provider for an asset +class ImLocalImageProvider extends ImageProvider { + final Asset asset; + + ImLocalImageProvider({required this.asset}) + : assert(asset.localId != null, 'Only usable when asset.local is set'); + + /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key + /// that describes the precise image to load. + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + ImLocalImageProvider key, + ImageDecoderCallback decode, + ) { + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(key.asset, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + informationCollector: () sync* { + yield ErrorDescription(asset.name); + }, + ); + } + + // Streams in each stage of the image as we ask for it + Stream _codec( + Asset a, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async* { + // Load a small thumbnail + final thumbBytes = + await di().getThumbnail(a.localId!); + if (thumbBytes != null) { + final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + final codec = await decode(buffer); + yield codec; + } else { + debugPrint("Loading thumb for ${a.name} failed"); + } + + if (asset.isImage) { + /// Using 2K thumbnail for local iOS image to avoid double swiping issue + if (Platform.isIOS) { + final largeImageBytes = await di() + .getThumbnail(a.localId!, width: 3840, height: 2160); + + if (largeImageBytes == null) { + throw StateError("Loading thumb for local photo ${a.name} failed"); + } + final buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes); + final codec = await decode(buffer); + yield codec; + } else { + // Use the original file for Android + final File? file = + await di().getOriginalFile(a.localId!); + if (file == null) { + throw StateError("Opening file for asset ${a.name} failed"); + } + try { + final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); + final codec = await decode(buffer); + yield codec; + } catch (error, stack) { + Error.throwWithStackTrace( + StateError("Loading asset ${a.name} failed"), + stack, + ); + } finally { + await file.deleteDarwinCache(); + } + } + } + + await chunkEvents.close(); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is ImLocalImageProvider) { + return asset == other.asset; + } + + return false; + } + + @override + int get hashCode => asset.hashCode; +} diff --git a/mobile-v2/lib/presentation/components/image/provider/immich_local_thumbnail_provider.dart b/mobile-v2/lib/presentation/components/image/provider/immich_local_thumbnail_provider.dart new file mode 100644 index 0000000000..e602dcc485 --- /dev/null +++ b/mobile-v2/lib/presentation/components/image/provider/immich_local_thumbnail_provider.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/constants/globals.dart'; + +/// The local image provider for an asset +/// Only viable +class ImLocalThumbnailProvider extends ImageProvider { + final Asset asset; + final int height; + final int width; + + ImLocalThumbnailProvider({ + required this.asset, + this.height = kGridThumbnailSize, + this.width = kGridThumbnailSize, + }) : assert(asset.localId != null, 'Only usable when asset.local is set'); + + /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key + /// that describes the precise image to load. + @override + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + ImLocalThumbnailProvider key, + ImageDecoderCallback decode, + ) { + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(key.asset, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + informationCollector: () sync* { + yield ErrorDescription(asset.name); + }, + ); + } + + // Streams in each stage of the image as we ask for it + Stream _codec( + Asset a, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async* { + // Load a small thumbnail + final thumbBytes = await di() + .getThumbnail(a.localId!, width: 32, height: 32, quality: 75); + if (thumbBytes != null) { + final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + final codec = await decode(buffer); + yield codec; + } else { + debugPrint("Loading thumb for ${a.name} failed"); + } + + final normalThumbBytes = await di() + .getThumbnail(a.localId!, width: width, height: height); + if (normalThumbBytes == null) { + throw StateError("Loading thumb for local photo ${a.name} failed"); + } + final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes); + final codec = await decode(buffer); + yield codec; + + await chunkEvents.close(); + } + + @override + bool operator ==(Object other) { + if (other is! ImLocalThumbnailProvider) return false; + if (identical(this, other)) return true; + return asset == other.asset; + } + + @override + int get hashCode => asset.hashCode; +} diff --git a/mobile-v2/lib/presentation/components/image/provider/immich_remote_image_provider.dart b/mobile-v2/lib/presentation/components/image/provider/immich_remote_image_provider.dart new file mode 100644 index 0000000000..b3afa2fb6b --- /dev/null +++ b/mobile-v2/lib/presentation/components/image/provider/immich_remote_image_provider.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/presentation/components/image/cache/cache_manager.dart'; +import 'package:immich_mobile/presentation/components/image/cache/image_loader.dart'; +import 'package:immich_mobile/utils/immich_image_url_helper.dart'; + +/// The remote image provider for full size remote images +class ImRemoteImageProvider extends ImageProvider { + /// The [Asset.remoteId] of the asset to fetch + final String assetId; + + /// The image cache manager + final CacheManager? cacheManager; + + const ImRemoteImageProvider({required this.assetId, this.cacheManager}); + + /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key + /// that describes the precise image to load. + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + ImRemoteImageProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? ImRemoteImageCacheManager(); + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(key, cache, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + ); + } + + // Streams in each stage of the image as we ask for it + Stream _codec( + ImRemoteImageProvider key, + CacheManager cache, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async* { + // Load a preview to the chunk events + final preview = ImImageUrlHelper.getThumbnailUrlForRemoteId(key.assetId); + + yield await ImageLoader.loadImageFromCache( + preview, + cache: cache, + decode: decode, + chunkEvents: chunkEvents, + ); + + // Load the higher resolution version of the image + final url = ImImageUrlHelper.getThumbnailUrlForRemoteId( + key.assetId, + type: AssetMediaSize.preview, + ); + final codec = await ImageLoader.loadImageFromCache( + url, + cache: cache, + decode: decode, + chunkEvents: chunkEvents, + ); + yield codec; + + await chunkEvents.close(); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is ImRemoteImageProvider) { + return assetId == other.assetId; + } + + return false; + } + + @override + int get hashCode => assetId.hashCode; +} diff --git a/mobile-v2/lib/presentation/components/image/provider/immich_remote_thumbnail_provider.dart b/mobile-v2/lib/presentation/components/image/provider/immich_remote_thumbnail_provider.dart new file mode 100644 index 0000000000..b894fbc248 --- /dev/null +++ b/mobile-v2/lib/presentation/components/image/provider/immich_remote_thumbnail_provider.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:immich_mobile/presentation/components/image/cache/cache_manager.dart'; +import 'package:immich_mobile/presentation/components/image/cache/image_loader.dart'; +import 'package:immich_mobile/utils/immich_image_url_helper.dart'; + +/// The remote image provider +class ImmichRemoteThumbnailProvider + extends ImageProvider { + /// The [Asset.remoteId] of the asset to fetch + final String assetId; + + final int? height; + final int? width; + + /// The image cache manager + final CacheManager? cacheManager; + + const ImmichRemoteThumbnailProvider({ + required this.assetId, + this.height, + this.width, + this.cacheManager, + }); + + /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key + /// that describes the precise image to load. + @override + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + ImmichRemoteThumbnailProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? ImRemoteThumbnailCacheManager(); + return MultiImageStreamCompleter( + codec: _codec(key, cache, decode), + scale: 1.0, + ); + } + + // Streams in each stage of the image as we ask for it + Stream _codec( + ImmichRemoteThumbnailProvider key, + CacheManager cache, + ImageDecoderCallback decode, + ) async* { + final preview = ImImageUrlHelper.getThumbnailUrlForRemoteId(key.assetId); + + yield await ImageLoader.loadImageFromCache( + preview, + cache: cache, + decode: decode, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is ImmichRemoteThumbnailProvider) { + return assetId == other.assetId; + } + + return false; + } + + @override + int get hashCode => assetId.hashCode; +} diff --git a/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart b/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart index bb6190b9ae..9186cc3209 100644 --- a/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart +++ b/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart @@ -17,6 +17,7 @@ class ImAdaptiveRoutePrimaryAppBar extends StatelessWidget Size get preferredSize => const Size.fromHeight(kToolbarHeight); } +// ignore: prefer-single-widget-per-file class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget implements PreferredSizeWidget { const ImAdaptiveRouteSecondaryAppBar({super.key}); diff --git a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart index b3b07f3391..d1cb87079f 100644 --- a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart +++ b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart @@ -15,7 +15,7 @@ class HomePage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: BlocProvider( - create: (_) => ImmichAssetGridCubit( + create: (_) => AssetGridCubit( renderStream: di().watchAll(), assetProvider: di().getAll, ), diff --git a/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart b/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart index c57d982ab4..68f6406885 100644 --- a/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart +++ b/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart @@ -5,12 +5,14 @@ import 'package:immich_mobile/domain/interfaces/asset.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/album_sync.service.dart'; import 'package:immich_mobile/domain/services/asset_sync.service.dart'; import 'package:immich_mobile/domain/services/login.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/i18n/strings.g.dart'; -import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart'; import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart'; +import 'package:immich_mobile/presentation/states/gallery_permission.state.dart'; +import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart'; import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/utils/immich_api_client.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; @@ -126,7 +128,7 @@ class LoginPageCubit extends Cubit with LogMixin { await di().upsert(StoreKey.accessToken, accessToken); /// Set token to interceptor - await di().init(accessToken: accessToken); + await di().init(accessToken: accessToken); final user = await di().getMyUser(); if (user == null) { @@ -139,7 +141,9 @@ class LoginPageCubit extends Cubit with LogMixin { await di().upsert(user); // Remove and Sync assets in background await di().deleteAll(); - unawaited(di().performFullRemoteSyncForUser(user)); + await di().requestPermission(); + unawaited(di().performFullRemoteSyncIsolate(user)); + unawaited(di().performFullDeviceSyncIsolate()); emit(state.copyWith( isValidationInProgress: false, diff --git a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart index fe381cd394..e3639e6119 100644 --- a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart +++ b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart @@ -10,9 +10,9 @@ import 'package:immich_mobile/presentation/components/input/filled_button.widget import 'package:immich_mobile/presentation/components/input/password_form_field.widget.dart'; import 'package:immich_mobile/presentation/components/input/text_button.widget.dart'; import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart'; -import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart'; import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart'; import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart'; +import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart'; import 'package:immich_mobile/service_locator.dart'; import 'package:material_symbols_icons/symbols.dart'; diff --git a/mobile-v2/lib/presentation/modules/settings/models/settings_section.model.dart b/mobile-v2/lib/presentation/modules/settings/models/settings_section.model.dart index d2a56d4dd7..3d304b680e 100644 --- a/mobile-v2/lib/presentation/modules/settings/models/settings_section.model.dart +++ b/mobile-v2/lib/presentation/modules/settings/models/settings_section.model.dart @@ -4,17 +4,17 @@ import 'package:immich_mobile/presentation/router/router.dart'; import 'package:material_symbols_icons/symbols.dart'; enum SettingSection { - general( + general._( icon: Symbols.interests_rounded, labelKey: 'settings.sections.general', destination: GeneralSettingsRoute(), ), - advance( + advance._( icon: Symbols.build_rounded, labelKey: 'settings.sections.advance', destination: AdvanceSettingsRoute(), ), - about( + about._( icon: Symbols.help_rounded, labelKey: 'settings.sections.about', destination: AboutSettingsRoute(), @@ -24,7 +24,7 @@ enum SettingSection { final String labelKey; final IconData icon; - const SettingSection({ + const SettingSection._({ required this.labelKey, required this.icon, required this.destination, diff --git a/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart index 740a8838e7..253a971eb1 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart'; import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart'; +import 'package:immich_mobile/utils/constants/globals.dart'; import 'package:immich_mobile/utils/constants/size_constants.dart'; @RoutePage() @@ -17,9 +18,10 @@ class AboutSettingsPage extends StatelessWidget { title: Text(context.t.settings.about.third_party_title), subtitle: Text(context.t.settings.about.third_party_sub_title), onTap: () => showLicensePage( - context: context, - applicationName: "Immich", - applicationIcon: const ImLogo(width: SizeConstants.xl)), + context: context, + applicationName: kImmichAppName, + applicationIcon: const ImLogo(width: SizeConstants.xl), + ), ), ); } diff --git a/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart index 834a678063..0d63f8556c 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart @@ -22,6 +22,7 @@ class SettingsWrapperPage extends StatelessWidget { } @RoutePage() +// ignore: prefer-single-widget-per-file class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); diff --git a/mobile-v2/lib/presentation/modules/theme/models/app_theme.model.dart b/mobile-v2/lib/presentation/modules/theme/models/app_theme.model.dart index bbd26875f5..c29675b2a7 100644 --- a/mobile-v2/lib/presentation/modules/theme/models/app_theme.model.dart +++ b/mobile-v2/lib/presentation/modules/theme/models/app_theme.model.dart @@ -3,14 +3,14 @@ import 'package:immich_mobile/presentation/modules/theme/models/app_colors.model import 'package:immich_mobile/utils/extensions/material_state.extension.dart'; enum AppTheme { - blue(AppColors.blueLight, AppColors.blueDark), + blue._(AppColors.blueLight, AppColors.blueDark), // Fallback color for dynamic theme for non-supported platforms - dynamic(AppColors.blueLight, AppColors.blueDark); + dynamic._(AppColors.blueLight, AppColors.blueDark); final ColorScheme lightSchema; final ColorScheme darkSchema; - const AppTheme(this.lightSchema, this.darkSchema); + const AppTheme._(this.lightSchema, this.darkSchema); static ThemeData generateThemeData(ColorScheme color) { return ThemeData( @@ -51,6 +51,14 @@ enum AppTheme { borderSide: BorderSide(color: color.outlineVariant), borderRadius: const BorderRadius.all(Radius.circular(15)), ), + errorBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(15)), + borderSide: BorderSide(color: color.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(15)), + borderSide: BorderSide(color: color.error), + ), hintStyle: const TextStyle( fontSize: 16.0, fontWeight: FontWeight.normal, diff --git a/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart b/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart index 9469cfd0ba..2a8b0464c9 100644 --- a/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart +++ b/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart @@ -3,12 +3,13 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:immich_mobile/domain/services/album_sync.service.dart'; import 'package:immich_mobile/domain/services/asset_sync.service.dart'; import 'package:immich_mobile/domain/services/login.service.dart'; import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart'; -import 'package:immich_mobile/presentation/modules/common/states/current_user.state.dart'; import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart'; import 'package:immich_mobile/presentation/router/router.dart'; +import 'package:immich_mobile/presentation/states/current_user.state.dart'; import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; @@ -53,7 +54,8 @@ class _SplashScreenState extends State Future _tryLogin() async { if (await di().tryAutoLogin() && mounted) { unawaited(di() - .performFullRemoteSyncForUser(di().state)); + .performFullRemoteSyncIsolate(di().state)); + unawaited(di().performFullDeviceSyncIsolate()); unawaited(context.replaceRoute(const TabControllerRoute())); } else { unawaited(context.replaceRoute(const LoginRoute())); diff --git a/mobile-v2/lib/presentation/modules/common/states/current_user.state.dart b/mobile-v2/lib/presentation/states/current_user.state.dart similarity index 100% rename from mobile-v2/lib/presentation/modules/common/states/current_user.state.dart rename to mobile-v2/lib/presentation/states/current_user.state.dart diff --git a/mobile-v2/lib/presentation/states/gallery_permission.state.dart b/mobile-v2/lib/presentation/states/gallery_permission.state.dart new file mode 100644 index 0000000000..ab912f9cbe --- /dev/null +++ b/mobile-v2/lib/presentation/states/gallery_permission.state.dart @@ -0,0 +1,123 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; + +enum GalleryPermissionStatus { + yetToRequest, + granted, + limited, + denied, + permanentlyDenied; + + bool get isGranted => this == GalleryPermissionStatus.granted; + bool get isLimited => this == GalleryPermissionStatus.limited; +} + +class GalleryPermissionNotifier extends ValueNotifier { + GalleryPermissionNotifier() : super(GalleryPermissionStatus.yetToRequest) { + checkPermission(); + } + + bool get hasPermission => value.isGranted || value.isLimited; + + /// Requests the gallery permission + Future requestPermission() async { + PermissionStatus result; + // Android 32 and below uses Permission.storage + if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + if (androidInfo.version.sdkInt <= 32) { + // Android 32 and below need storage + final permission = await Permission.storage.request(); + result = permission; + } else { + // Android 33 need photo & video + final photos = await Permission.photos.request(); + if (!photos.isGranted) { + final state = _toGalleryPermissionStatus(photos); + // Don't ask twice for the same permission + value = state; + return state; + } + + final videos = await Permission.videos.request(); + // Return the joint result of those two permissions + if ((photos.isGranted && videos.isGranted) || + (photos.isLimited && videos.isLimited)) { + result = PermissionStatus.granted; + } else if (photos.isDenied || videos.isDenied) { + result = PermissionStatus.denied; + } else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) { + result = PermissionStatus.permanentlyDenied; + } else { + result = PermissionStatus.denied; + } + } + if (result == PermissionStatus.granted && + androidInfo.version.sdkInt >= 29) { + result = await Permission.accessMediaLocation.request(); + } + } else { + // iOS can use photos + result = await Permission.photos.request(); + } + value = _toGalleryPermissionStatus(result); + return value; + } + + /// Checks the current state of the gallery permissions without + /// requesting them again + Future checkPermission() async { + PermissionStatus result; + // Android 32 and below uses Permission.storage + if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + if (androidInfo.version.sdkInt <= 32) { + // Android 32 and below need storage + final permission = await Permission.storage.status; + result = permission; + } else { + // Android 33 needs photo & video + final photos = await Permission.photos.status; + final videos = await Permission.videos.status; + + // Return the joint result of those two permissions + final PermissionStatus status; + if ((photos.isGranted && videos.isGranted) || + (photos.isLimited && videos.isLimited)) { + status = PermissionStatus.granted; + } else if (photos.isDenied || videos.isDenied) { + status = PermissionStatus.denied; + } else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) { + status = PermissionStatus.permanentlyDenied; + } else { + status = PermissionStatus.denied; + } + + result = status; + } + if (result == PermissionStatus.granted && + androidInfo.version.sdkInt >= 29) { + result = await Permission.accessMediaLocation.status; + } + } else { + // iOS can use photos + result = await Permission.photos.status; + } + value = _toGalleryPermissionStatus(result); + return value; + } +} + +GalleryPermissionStatus _toGalleryPermissionStatus(PermissionStatus status) => + switch (status) { + PermissionStatus.granted => GalleryPermissionStatus.granted, + PermissionStatus.limited => GalleryPermissionStatus.limited, + PermissionStatus.denied => GalleryPermissionStatus.denied, + PermissionStatus.restricted || + PermissionStatus.permanentlyDenied || + PermissionStatus.provisional => + GalleryPermissionStatus.permanentlyDenied, + }; diff --git a/mobile-v2/lib/presentation/modules/common/states/server_info/server_feature_config.state.dart b/mobile-v2/lib/presentation/states/server_info/server_feature_config.state.dart similarity index 100% rename from mobile-v2/lib/presentation/modules/common/states/server_info/server_feature_config.state.dart rename to mobile-v2/lib/presentation/states/server_info/server_feature_config.state.dart diff --git a/mobile-v2/lib/service_locator.dart b/mobile-v2/lib/service_locator.dart index 66dffced39..31d079e166 100644 --- a/mobile-v2/lib/service_locator.dart +++ b/mobile-v2/lib/service_locator.dart @@ -1,25 +1,42 @@ import 'package:get_it/get_it.dart'; +import 'package:immich_mobile/domain/interfaces/album.interface.dart'; +import 'package:immich_mobile/domain/interfaces/album_asset.interface.dart'; +import 'package:immich_mobile/domain/interfaces/album_etag.interface.dart'; import 'package:immich_mobile/domain/interfaces/asset.interface.dart'; +import 'package:immich_mobile/domain/interfaces/database.interface.dart'; +import 'package:immich_mobile/domain/interfaces/device_album.interface.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset_hash.interface.dart'; import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/interfaces/renderlist.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/repositories/album.repository.dart'; +import 'package:immich_mobile/domain/repositories/album_asset.repository.dart'; +import 'package:immich_mobile/domain/repositories/album_etag.repository.dart'; import 'package:immich_mobile/domain/repositories/asset.repository.dart'; import 'package:immich_mobile/domain/repositories/database.repository.dart'; +import 'package:immich_mobile/domain/repositories/device_album.repository.dart'; +import 'package:immich_mobile/domain/repositories/device_asset.repository.dart'; +import 'package:immich_mobile/domain/repositories/device_asset_hash.repository.dart'; import 'package:immich_mobile/domain/repositories/log.repository.dart'; import 'package:immich_mobile/domain/repositories/renderlist.repository.dart'; import 'package:immich_mobile/domain/repositories/store.repository.dart'; import 'package:immich_mobile/domain/repositories/user.repository.dart'; +import 'package:immich_mobile/domain/services/album_sync.service.dart'; import 'package:immich_mobile/domain/services/app_setting.service.dart'; import 'package:immich_mobile/domain/services/asset_sync.service.dart'; +import 'package:immich_mobile/domain/services/hash.service.dart'; import 'package:immich_mobile/domain/services/login.service.dart'; import 'package:immich_mobile/domain/services/server_info.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; -import 'package:immich_mobile/presentation/modules/common/states/current_user.state.dart'; -import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart'; +import 'package:immich_mobile/platform/messages.g.dart'; import 'package:immich_mobile/presentation/modules/theme/states/app_theme.state.dart'; import 'package:immich_mobile/presentation/router/router.dart'; +import 'package:immich_mobile/presentation/states/current_user.state.dart'; +import 'package:immich_mobile/presentation/states/gallery_permission.state.dart'; +import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart'; import 'package:immich_mobile/utils/immich_api_client.dart'; final di = GetIt.I; @@ -55,7 +72,7 @@ class ServiceLocator { static void configureServicesForIsolate({ required DriftDatabaseRepository database, - required ImmichApiClient apiClient, + required ImApiClient apiClient, }) { _registerSingleton(database); _registerSingleton(apiClient); @@ -66,36 +83,58 @@ class ServiceLocator { static void _registerRepositories() { /// Repositories - _registerFactory(() => StoreDriftRepository(di())); - _registerFactory(() => LogDriftRepository(di())); + _registerSingleton(di()); + _registerFactory(() => StoreRepository(di())); + _registerFactory(() => LogRepository(di())); _registerFactory(() => AppSettingService(di())); - _registerFactory(() => UserDriftRepository(di())); - _registerFactory(() => AssetDriftRepository(di())); - _registerFactory( - () => RenderListDriftRepository(di()), + _registerFactory(() => UserRepository(di())); + _registerFactory(() => AssetRepository(di())); + _registerFactory(() => AlbumRepository(di())); + _registerFactory( + () => const DeviceAssetRepository(), ); + _registerFactory(() => RenderListRepository(di())); + _registerFactory( + () => DeviceAssetToHashRepository(di()), + ); + _registerFactory( + () => const DeviceAlbumRepository(), + ); + _registerFactory( + () => AlbumToAssetRepository(di()), + ); + _registerFactory(() => AlbumETagRepository(di())); /// Services _registerFactory(() => const LoginService()); + _registerSingleton(ImHostService()); + _registerSingleton(const AlbumSyncService()); + _registerFactory(() => HashService( + hostService: di(), + assetToHashRepo: di(), + deviceAlbumRepo: di(), + deviceAssetRepo: di(), + )); } static void _registerPreGlobalStates() { _registerSingleton(AppRouter()); _registerLazySingleton(() => AppThemeCubit(di())); + _registerSingleton(GalleryPermissionNotifier()); } static void registerApiClient(String endpoint) { - _registerSingleton(ImmichApiClient(endpoint: endpoint)); + _registerSingleton(ImApiClient(endpoint: endpoint)); } static void registerPostValidationServices() { _registerFactory(() => UserService( - di().getUsersApi(), + di().getUsersApi(), )); _registerFactory(() => ServerInfoService( - di().getServerApi(), + di().getServerApi(), )); - _registerFactory(() => const AssetSyncService()); + _registerSingleton(const AssetSyncService()); } static void registerPostGlobalStates() { diff --git a/mobile-v2/lib/utils/collection_util.dart b/mobile-v2/lib/utils/collection_util.dart index a62a08b00f..89bb315ff2 100644 --- a/mobile-v2/lib/utils/collection_util.dart +++ b/mobile-v2/lib/utils/collection_util.dart @@ -1,5 +1,7 @@ // ignore_for_file: avoid-unsafe-collection-methods +import 'dart:async'; + class CollectionUtil { const CollectionUtil(); @@ -13,28 +15,33 @@ class CollectionUtil { return a.compareTo(b); } - /// Find the difference between the two sorted lists [first] and [second] + /// Find the difference between the two lists [first] and [second] /// Results are passed as callbacks back to the caller during the comparison - static bool diffSortedLists( + static FutureOr diffLists( List first, List second, { - required Comparator compare, - required bool Function(T a, T b) both, - required void Function(T a) onlyFirst, - required void Function(T b) onlySecond, - }) { + required int Function(T a, T b) compare, + required FutureOr Function(T a, T b) both, + required FutureOr Function(T a) onlyFirst, + required FutureOr Function(T b) onlySecond, + }) async { + first.sort(compare); + first.uniqueConsecutive(compare); + second.sort(compare); + second.uniqueConsecutive(compare); + bool diff = false; int i = 0, j = 0; for (; i < first.length && j < second.length;) { final int order = compare(first[i], second[j]); if (order == 0) { - diff |= both(first[i++], second[j++]); + diff |= await both(first[i++], second[j++]); } else if (order < 0) { - onlyFirst(first[i++]); + await onlyFirst(first[i++]); diff = true; } else if (order > 0) { - onlySecond(second[j++]); + await onlySecond(second[j++]); diff = true; } } @@ -50,3 +57,19 @@ class CollectionUtil { return diff; } } + +extension _ListExtension on List { + List uniqueConsecutive(int Function(T a, T b) compare) { + int i = 1, j = 1; + for (; i < length; i++) { + if (compare(this[i - 1], this[i]) != 0) { + if (i != j) { + this[j] = this[i]; + } + j++; + } + } + length = length == 0 ? 0 : j; + return this; + } +} diff --git a/mobile-v2/lib/utils/constants/globals.dart b/mobile-v2/lib/utils/constants/globals.dart index 1742911876..3708cf9df2 100644 --- a/mobile-v2/lib/utils/constants/globals.dart +++ b/mobile-v2/lib/utils/constants/globals.dart @@ -1,14 +1,29 @@ import 'package:flutter/material.dart'; +const String kImmichAppName = "Immich"; + /// Log messages stored in the DB const int kLogMessageLimit = 500; +/// Cache constants +const int kCacheStalePeriod = 30; // in days +const String kCacheFullImagesKey = 'ImFullImageCacheKey'; +const int kCacheMaxNrOfFullImages = 500; +const String kCacheThumbnailsKey = 'ImThumbnailCacheKey'; +const int kCacheMaxNrOfThumbnails = 500; + +/// Grid constants +const int kGridThumbnailSize = 200; +const int kGridThumbnailQuality = 80; + /// RenderList constants const int kRenderListBatchSize = 512; const int kRenderListOppositeBatchSize = 128; -/// Chunked asset sync size +/// Sync constants const int kFullSyncChunkSize = 10000; +const int kHashAssetsFileLimit = 128; +const int kHashAssetsSizeLimit = 1024 * 1024 * 1024; // 1GB /// Headers // Auth header diff --git a/mobile-v2/lib/utils/extensions/file.extension.dart b/mobile-v2/lib/utils/extensions/file.extension.dart new file mode 100644 index 0000000000..caedc09e9e --- /dev/null +++ b/mobile-v2/lib/utils/extensions/file.extension.dart @@ -0,0 +1,11 @@ +import 'dart:io'; + +extension ClearPhotoManagerCacheExtension on File { + Future deleteDarwinCache() async { + if (Platform.isIOS) { + try { + await delete(); + } catch (_) {} + } + } +} diff --git a/mobile-v2/lib/utils/immich_api_client.dart b/mobile-v2/lib/utils/immich_api_client.dart index 15925bf6c7..111291e6dd 100644 --- a/mobile-v2/lib/utils/immich_api_client.dart +++ b/mobile-v2/lib/utils/immich_api_client.dart @@ -10,8 +10,8 @@ import 'package:immich_mobile/utils/constants/globals.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; import 'package:openapi/api.dart'; -class ImmichApiClient extends ApiClient with LogMixin { - ImmichApiClient({required String endpoint}) : super(basePath: endpoint); +class ImApiClient extends ApiClient with LogMixin { + ImApiClient({required String endpoint}) : super(basePath: endpoint); Map get headers => defaultHeaderMap; diff --git a/mobile-v2/lib/utils/immich_image_url_helper.dart b/mobile-v2/lib/utils/immich_image_url_helper.dart index ca0063027c..7a9086f5a9 100644 --- a/mobile-v2/lib/utils/immich_image_url_helper.dart +++ b/mobile-v2/lib/utils/immich_image_url_helper.dart @@ -1,28 +1,36 @@ import 'package:immich_mobile/domain/models/asset.model.dart'; import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/utils/immich_api_client.dart'; -import 'package:openapi/api.dart'; + +enum AssetMediaSize { + preview._('preview'), + thumbnail._('thumbnail'); + + const AssetMediaSize._(this.value); + + final String value; +} class ImImageUrlHelper { const ImImageUrlHelper(); - static String get _serverUrl => di().basePath; + static String get _serverUrl => di().basePath; static String getThumbnailUrl( final Asset asset, { AssetMediaSize type = AssetMediaSize.thumbnail, }) { - return _getThumbnailUrlForRemoteId(asset.remoteId!, type: type); + return getThumbnailUrlForRemoteId(asset.remoteId!, type: type); } static String getThumbnailCacheKey( final Asset asset, { AssetMediaSize type = AssetMediaSize.thumbnail, }) { - return _getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type); + return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type); } - static String _getThumbnailCacheKeyForRemoteId( + static String getThumbnailCacheKeyForRemoteId( final String id, { AssetMediaSize type = AssetMediaSize.thumbnail, }) { @@ -32,7 +40,7 @@ class ImImageUrlHelper { return 'preview-image-$id'; } - static String _getThumbnailUrlForRemoteId( + static String getThumbnailUrlForRemoteId( final String id, { AssetMediaSize type = AssetMediaSize.thumbnail, }) { diff --git a/mobile-v2/lib/utils/isolate_helper.dart b/mobile-v2/lib/utils/isolate_helper.dart index d8c11eddcb..1a87ed40b3 100644 --- a/mobile-v2/lib/utils/isolate_helper.dart +++ b/mobile-v2/lib/utils/isolate_helper.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:isolate'; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -32,7 +33,7 @@ class IsolateHelper { IsolateHelper(); void preIsolateHandling() { - final apiClient = di(); + final apiClient = di(); _clientData = _ImApiClientData( endpoint: apiClient.basePath, headersMap: apiClient.defaultHeaderMap, @@ -42,7 +43,7 @@ class IsolateHelper { void postIsolateHandling() { assert(_clientData != null); // Reconstruct client from cached data - final client = ImmichApiClient(endpoint: _clientData!.endpoint); + final client = ImApiClient(endpoint: _clientData!.endpoint); for (final entry in _clientData.headersMap.entries) { client.addDefaultHeader(entry.key, entry.value); } @@ -54,7 +55,7 @@ class IsolateHelper { ); // Init log manager to continue listening to log events - LogManager.I.init(); + LogManager.I.init(shouldBuffer: false); } static Future run(FutureOr Function() computation) async { @@ -66,9 +67,15 @@ class IsolateHelper { final helper = IsolateHelper()..preIsolateHandling(); return await Isolate.run(() async { BackgroundIsolateBinaryMessenger.ensureInitialized(token); + DartPluginRegistrant.ensureInitialized(); + // Delay to ensure the isolate is ready + await Future.delayed(const Duration(milliseconds: 100)); helper.postIsolateHandling(); try { - return await computation(); + final result = await computation(); + // Delay to ensure the isolate is not killed prematurely + await Future.delayed(const Duration(milliseconds: 100)); + return result; } finally { // Always close the new database connection on Isolate end await di().close(); diff --git a/mobile-v2/lib/utils/log_manager.dart b/mobile-v2/lib/utils/log_manager.dart index dffbf7dcbc..56f1d7e5c2 100644 --- a/mobile-v2/lib/utils/log_manager.dart +++ b/mobile-v2/lib/utils/log_manager.dart @@ -21,6 +21,11 @@ class LogManager { List _msgBuffer = []; Timer? _timer; + + /// Whether to buffer logs in memory before writing to the database. + /// This is useful when logging in quick succession, as it increases performance + /// and reduces NAND wear. However, it may cause the logs to be lost in case of a crash / in isolates. + bool _shouldBuffer = true; late final StreamSubscription _subscription; void _onLogRecord(logging.LogRecord record) { @@ -42,11 +47,14 @@ class LogManager { error: record.error?.toString(), stack: record.stackTrace?.toString(), ); - _msgBuffer.add(lm); - // delayed batch writing to database: increases performance when logging - // messages in quick succession and reduces NAND wear - _timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase); + if (_shouldBuffer) { + _msgBuffer.add(lm); + _timer ??= + Timer(const Duration(seconds: 5), () => _flushBufferToDatabase()); + } else { + di().create(lm); + } } void _flushBufferToDatabase() { @@ -56,7 +64,8 @@ class LogManager { di().createAll(buffer); } - void init() { + void init({bool? shouldBuffer}) { + _shouldBuffer = shouldBuffer ?? _shouldBuffer; _subscription = logging.Logger.root.onRecord.listen(_onLogRecord); } @@ -106,18 +115,24 @@ class Logger { logging.Logger get _logger => logging.Logger(_loggerName); - // Highly detailed + /// Finest / Verbose logs. Useful for highly detailed messages void v(String message) => _logger.finest(message); - // Troubleshooting + + /// Fine / Debug logs. Useful for troubleshooting void d(String message) => _logger.fine(message); - // General purpose + + /// Info logs. Useful for general logging void i(String message) => _logger.info(message); - // Potential issues - void w(String message) => _logger.warning(message); - // Error + + /// Warning logs. Useful to identify potential issues + void w(String message, [Object? error, StackTrace? stack]) => + _logger.warning(message, error, stack); + + /// Error logs. Useful for identifying issues void e(String message, [Object? error, StackTrace? stack]) => _logger.severe(message, error, stack); - // Crash / Serious failure + + /// Crash / Serious failure logs. Shouldn't happen void wtf(String message, [Object? error, StackTrace? stack]) => _logger.shout(message, error, stack); } diff --git a/mobile-v2/pubspec.lock b/mobile-v2/pubspec.lock index 57689471eb..765b3a399e 100644 --- a/mobile-v2/pubspec.lock +++ b/mobile-v2/pubspec.lock @@ -651,7 +651,7 @@ packages: source: hosted version: "1.0.0" octo_image: - dependency: transitive + dependency: "direct main" description: name: octo_image sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" @@ -753,6 +753,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + url: "https://pub.dev" + source: hosted + version: "11.3.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" + url: "https://pub.dev" + source: hosted + version: "12.0.13" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + url: "https://pub.dev" + source: hosted + version: "9.4.5" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851 + url: "https://pub.dev" + source: hosted + version: "0.1.3+2" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/mobile-v2/pubspec.yaml b/mobile-v2/pubspec.yaml index c2f0026b34..0039797504 100644 --- a/mobile-v2/pubspec.yaml +++ b/mobile-v2/pubspec.yaml @@ -1,11 +1,11 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone -publish_to: 'none' +publish_to: "none" version: 1.102.0+132 environment: - sdk: '>=3.3.3 <4.0.0' + sdk: ">=3.3.3 <4.0.0" dependencies: flutter: @@ -20,6 +20,7 @@ dependencies: url_launcher: ^6.3.0 package_info_plus: ^8.0.2 device_info_plus: ^10.1.2 + permission_handler: ^11.3.1 # State handling flutter_bloc: ^8.1.6 # Database @@ -47,6 +48,7 @@ dependencies: # oauth login flutter_web_auth_2: ^3.1.2 # components + octo_image: ^2.1.0 material_symbols_icons: ^4.2785.1 flutter_adaptive_scaffold: ^0.3.1 flutter_list_view: ^1.1.28