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