Compare commits

..

8 Commits

Author SHA1 Message Date
mertalev 3946e8c0b2 don't close client 2025-09-02 13:17:40 -04:00
mertalev 1dbac30993 update other usages 2025-09-02 13:10:27 -04:00
mertalev 831fb5a2f9 set defaults 2025-09-02 13:03:30 -04:00
mertalev 8d272f8abc init before app launch 2025-09-02 12:38:11 -04:00
mertalev 3a2b572e0b custom user agent 2025-09-02 12:38:11 -04:00
mertalev 942b27241a fix hot reload 2025-09-02 12:38:11 -04:00
mertalev 482526475b uppercase http method 2025-09-02 12:38:11 -04:00
mertalev c4bd24277a platform clients 2025-09-02 12:38:11 -04:00
161 changed files with 2930 additions and 11299 deletions
-4
View File
@@ -34,7 +34,3 @@ The `/api/something` endpoint is now `/api/something-else`
- [ ] I have followed naming conventions/patterns in the surrounding code - [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc. - [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`) - [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
## Please describe to which degree, if any, an LLM was used in creating this pull request.
...
+5 -9
View File
@@ -10,14 +10,14 @@ dev-update: prepare-volumes
dev-scale: prepare-volumes dev-scale: prepare-volumes
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
dev-docs: dev-docs: prepare-volumes
npm --prefix docs run start npm --prefix docs run start
.PHONY: e2e .PHONY: e2e
e2e: e2e: prepare-volumes
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
e2e-update: e2e-update: prepare-volumes
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-down: e2e-down:
@@ -73,8 +73,6 @@ define safe_chown
if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \ if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \
true; \ true; \
else \ else \
STATUS=$$?; echo "Exit code: $$STATUS $(1)"; \
echo "$$STATUS $(1)"; \
echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \ echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \
exit 1; \ exit 1; \
fi; fi;
@@ -85,13 +83,11 @@ prepare-volumes:
@$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R)) @$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R))
ifneq ($(UPLOAD_LOCATION),) ifneq ($(UPLOAD_LOCATION),)
ifeq ($(filter /%,$(UPLOAD_LOCATION)),) ifeq ($(filter /%,$(UPLOAD_LOCATION)),)
@mkdir -p "docker/$(UPLOAD_LOCATION)/photos/upload" @mkdir -p "docker/$(UPLOAD_LOCATION)"
@$(call safe_chown,docker/$(UPLOAD_LOCATION),) @$(call safe_chown,docker/$(UPLOAD_LOCATION),)
@$(call safe_chown,docker/$(UPLOAD_LOCATION)/photos,-R)
else else
@mkdir -p "$(UPLOAD_LOCATION)/photos/upload" @mkdir -p "$(UPLOAD_LOCATION)"
@$(call safe_chown,$(UPLOAD_LOCATION),) @$(call safe_chown,$(UPLOAD_LOCATION),)
@$(call safe_chown,$(UPLOAD_LOCATION)/photos,-R)
endif endif
endif endif
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.87", "version": "2.2.86",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.18.0", "@types/node": "^22.17.1",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
-4
View File
@@ -1,8 +1,4 @@
[ [
{
"label": "v1.141.0",
"url": "https://v1.141.0.archive.immich.app"
},
{ {
"label": "v1.140.1", "label": "v1.140.1",
"url": "https://v1.140.1.archive.immich.app" "url": "https://v1.140.1.archive.immich.app"
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.141.0", "version": "1.140.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.18.0", "@types/node": "^22.17.1",
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -45,7 +45,7 @@
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.3", "sharp": "^0.34.0",
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
+1 -5
View File
@@ -1417,8 +1417,6 @@
"open_the_search_filters": "Open the search filters", "open_the_search_filters": "Open the search filters",
"options": "Options", "options": "Options",
"or": "or", "or": "or",
"organize_into_albums": "Organize into albums",
"organize_into_albums_description": "Put existing photos into albums using current sync settings",
"organize_your_library": "Organize your library", "organize_your_library": "Organize your library",
"original": "original", "original": "original",
"other": "Other", "other": "Other",
@@ -1559,7 +1557,6 @@
"purchase_server_description_2": "Supporter status", "purchase_server_description_2": "Supporter status",
"purchase_server_title": "Server", "purchase_server_title": "Server",
"purchase_settings_server_activated": "The server product key is managed by the admin", "purchase_settings_server_activated": "The server product key is managed by the admin",
"query_asset_id": "Query Asset ID",
"queue_status": "Queuing {count}/{total}", "queue_status": "Queuing {count}/{total}",
"rating": "Star rating", "rating": "Star rating",
"rating_clear": "Clear rating", "rating_clear": "Clear rating",
@@ -1738,7 +1735,7 @@
"select_user_for_sharing_page_err_album": "Failed to create album", "select_user_for_sharing_page_err_album": "Failed to create album",
"selected": "Selected", "selected": "Selected",
"selected_count": "{count, plural, other {# selected}}", "selected_count": "{count, plural, other {# selected}}",
"selected_gps_coordinates": "Selected GPS Coordinates", "selected_gps_coordinates": "selected gps coordinates",
"send_message": "Send message", "send_message": "Send message",
"send_welcome_email": "Send welcome email", "send_welcome_email": "Send welcome email",
"server_endpoint": "Server Endpoint", "server_endpoint": "Server Endpoint",
@@ -2080,7 +2077,6 @@
"view_next_asset": "View next asset", "view_next_asset": "View next asset",
"view_previous_asset": "View previous asset", "view_previous_asset": "View previous asset",
"view_qr_code": "View QR code", "view_qr_code": "View QR code",
"view_similar_photos": "View similar photos",
"view_stack": "View Stack", "view_stack": "View Stack",
"view_user": "View User", "view_user": "View User",
"viewer_remove_from_stack": "Remove from Stack", "viewer_remove_from_stack": "Remove from Stack",
-34
View File
@@ -1,34 +0,0 @@
[tools.dart]
version = "3.8.2"
backend = "asdf:dart"
[tools.flutter]
version = "3.32.8-stable"
backend = "asdf:flutter"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.31.4"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".platforms.linux-x64]
checksum = "blake3:e9df5b765df327e1248fccf2c6165a89d632a065667f99c01765bf3047b94955"
size = 8821083
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.31.4/dcm-linux-x64-release.zip"
[tools.node]
version = "22.18.0"
backend = "core:node"
[tools.node.platforms.linux-x64]
checksum = "sha256:a2e703725d8683be86bb5da967bf8272f4518bdaf10f21389e2b2c9eaeae8c8a"
size = 54824343
url = "https://nodejs.org/dist/v22.18.0/node-v22.18.0-linux-x64.tar.gz"
[tools.pnpm]
version = "10.14.0"
backend = "aqua:pnpm/pnpm"
[tools.pnpm.platforms.linux-x64]
checksum = "blake3:13dfa46b7173d3cad3bad60a756a492ecf0bce48b23eb9f793e7ccec5a09b46d"
size = 66231525
url = "https://github.com/pnpm/pnpm/releases/download/v10.14.0/pnpm-linux-x64"
-15
View File
@@ -1,15 +0,0 @@
[tools]
node = "22.18.0"
flutter = "3.32.8"
pnpm = "10.14.0"
dart = "3.8.2"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.31.4"
bin = "dcm"
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
[settings]
experimental = true
lockfile = true
pin = true
@@ -61,8 +61,9 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerFgHostApi { interface BackgroundWorkerFgHostApi {
fun enable() fun enableSyncWorker()
fun disable() fun enableUploadWorker(callbackHandle: Long)
fun disableUploadWorker()
companion object { companion object {
/** The codec used by BackgroundWorkerFgHostApi. */ /** The codec used by BackgroundWorkerFgHostApi. */
@@ -74,11 +75,11 @@ interface BackgroundWorkerFgHostApi {
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") { fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
api.enable() api.enableSyncWorker()
listOf(null) listOf(null)
} catch (exception: Throwable) { } catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception) BackgroundWorkerPigeonUtils.wrapError(exception)
@@ -90,11 +91,29 @@ interface BackgroundWorkerFgHostApi {
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val callbackHandleArg = args[0] as Long
val wrapped: List<Any?> = try {
api.enableUploadWorker(callbackHandleArg)
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
api.disable() api.disableUploadWorker()
listOf(null) listOf(null)
} catch (exception: Throwable) { } catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception) BackgroundWorkerPigeonUtils.wrapError(exception)
@@ -111,7 +130,6 @@ interface BackgroundWorkerFgHostApi {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerBgHostApi { interface BackgroundWorkerBgHostApi {
fun onInitialized() fun onInitialized()
fun close()
companion object { companion object {
/** The codec used by BackgroundWorkerBgHostApi. */ /** The codec used by BackgroundWorkerBgHostApi. */
@@ -138,22 +156,6 @@ interface BackgroundWorkerBgHostApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.close()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }
@@ -165,6 +167,23 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p
BackgroundWorkerPigeonCodec() BackgroundWorkerPigeonCodec()
} }
} }
fun onLocalSync(maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(maxSecondsArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName)))
}
}
}
fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit) fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
{ {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
@@ -11,11 +11,17 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.embedding.engine.dart.DartExecutor.DartCallback
import io.flutter.embedding.engine.loader.FlutterLoader import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.view.FlutterCallbackInformation
private const val TAG = "BackgroundWorker" private const val TAG = "BackgroundWorker"
enum class BackgroundTaskType {
LOCAL_SYNC,
UPLOAD,
}
class BackgroundWorker(context: Context, params: WorkerParameters) : class BackgroundWorker(context: Context, params: WorkerParameters) :
ListenableWorker(context, params), BackgroundWorkerBgHostApi { ListenableWorker(context, params), BackgroundWorkerBgHostApi {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
@@ -52,6 +58,25 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
engine = FlutterEngine(ctx) engine = FlutterEngine(ctx)
// Retrieve the callback handle stored by the main Flutter app
// This handle points to the Flutter function that should be executed in the background
val callbackHandle =
ctx.getSharedPreferences(BackgroundWorkerApiImpl.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(BackgroundWorkerApiImpl.SHARED_PREF_CALLBACK_HANDLE, 0L)
if (callbackHandle == 0L) {
// Without a valid callback handle, we cannot start the Flutter background execution
complete(Result.failure())
return@ensureInitializationCompleteAsync
}
// Start the Flutter engine with the specified callback as the entry point
val callback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
if (callback == null) {
complete(Result.failure())
return@ensureInitializationCompleteAsync
}
// Register custom plugins // Register custom plugins
MainActivity.registerPlugins(ctx, engine!!) MainActivity.registerPlugins(ctx, engine!!)
flutterApi = flutterApi =
@@ -61,12 +86,8 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
api = this api = this
) )
engine!!.dartExecutor.executeDartEntrypoint( engine!!.dartExecutor.executeDartCallback(
DartExecutor.DartEntrypoint( DartCallback(ctx.assets, loader.findAppBundlePath(), callback)
loader.findAppBundlePath(),
"package:immich_mobile/domain/services/background_worker.service.dart",
"backgroundSyncNativeEntrypoint"
)
) )
} }
@@ -79,10 +100,23 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
* This method acts as a bridge between the native Android background task system and Flutter. * This method acts as a bridge between the native Android background task system and Flutter.
*/ */
override fun onInitialized() { override fun onInitialized() {
flutterApi?.onAndroidUpload { handleHostResult(it) } val taskTypeIndex = inputData.getInt(BackgroundWorkerApiImpl.WORKER_DATA_TASK_TYPE, 0)
val taskType = BackgroundTaskType.entries[taskTypeIndex]
when (taskType) {
BackgroundTaskType.LOCAL_SYNC -> flutterApi?.onLocalSync(null) { handleHostResult(it) }
BackgroundTaskType.UPLOAD -> flutterApi?.onAndroidUpload { handleHostResult(it) }
}
} }
override fun close() { /**
* Called when the system has to stop this worker because constraints are
* no longer met or the system needs resources for more important tasks
* This is also called when the worker has been explicitly cancelled or replaced
*/
override fun onStopped() {
Log.d(TAG, "About to stop BackupWorker")
if (isComplete) { if (isComplete) {
return return
} }
@@ -100,16 +134,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
}, 5000) }, 5000)
} }
/**
* Called when the system has to stop this worker because constraints are
* no longer met or the system needs resources for more important tasks
* This is also called when the worker has been explicitly cancelled or replaced
*/
override fun onStopped() {
Log.d(TAG, "About to stop BackupWorker")
close()
}
private fun handleHostResult(result: kotlin.Result<Unit>) { private fun handleHostResult(result: kotlin.Result<Unit>) {
if (isComplete) { if (isComplete) {
return return
@@ -130,10 +154,8 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
* - Parameter success: Indicates whether the background task completed successfully * - Parameter success: Indicates whether the background task completed successfully
*/ */
private fun complete(success: Result) { private fun complete(success: Result) {
Log.d(TAG, "About to complete BackupWorker with result: $success")
isComplete = true isComplete = true
engine?.destroy() engine?.destroy()
engine = null
flutterApi = null flutterApi = null
completionHandler.set(success) completionHandler.set(success)
} }
@@ -3,8 +3,10 @@ package app.alextran.immich.background
import android.content.Context import android.content.Context
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.core.content.edit
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
@@ -14,13 +16,19 @@ private const val TAG = "BackgroundUploadImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
override fun enableSyncWorker() {
override fun enable() {
enqueueMediaObserver(ctx) enqueueMediaObserver(ctx)
Log.i(TAG, "Scheduled media observer")
} }
override fun disable() { override fun enableUploadWorker(callbackHandle: Long) {
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME) updateUploadEnabled(ctx, true)
updateCallbackHandle(ctx, callbackHandle)
Log.i(TAG, "Scheduled background upload tasks")
}
override fun disableUploadWorker() {
updateUploadEnabled(ctx, false)
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME) WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
Log.i(TAG, "Cancelled background upload tasks") Log.i(TAG, "Cancelled background upload tasks")
} }
@@ -29,14 +37,32 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1" private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
const val WORKER_DATA_TASK_TYPE = "taskType"
const val SHARED_PREF_NAME = "Immich::Background"
const val SHARED_PREF_BACKUP_ENABLED = "Background::backup::enabled"
const val SHARED_PREF_CALLBACK_HANDLE = "Background::backup::callbackHandle"
private fun updateUploadEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
putBoolean(SHARED_PREF_BACKUP_ENABLED, enabled)
}
}
private fun updateCallbackHandle(context: Context, callbackHandle: Long) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
putLong(SHARED_PREF_CALLBACK_HANDLE, callbackHandle)
}
}
fun enqueueMediaObserver(ctx: Context) { fun enqueueMediaObserver(ctx: Context) {
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(30, TimeUnit.SECONDS) .setTriggerContentUpdateDelay(5, TimeUnit.SECONDS)
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES) .setTriggerContentMaxDelay(1, TimeUnit.MINUTES)
.build() .build()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java) val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
@@ -48,13 +74,15 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME") Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
} }
fun enqueueBackgroundWorker(ctx: Context) { fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) {
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
val data = Data.Builder()
data.putInt(WORKER_DATA_TASK_TYPE, taskType.ordinal)
val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java) val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
.setConstraints(constraints) .setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build() .setInputData(data.build()).build()
WorkManager.getInstance(ctx) WorkManager.getInstance(ctx)
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work) .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
@@ -6,17 +6,29 @@ import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) { class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
override fun doWork(): Result { override fun doWork(): Result {
Log.i("MediaObserver", "Content change detected, starting background worker") Log.i("MediaObserver", "Content change detected, starting background worker")
// Re-enqueue itself to listen for future changes
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
// Enqueue backup worker only if there are new media changes // Enqueue backup worker only if there are new media changes
if (triggeredContentUris.isNotEmpty()) { if (triggeredContentUris.isNotEmpty()) {
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx) val type =
if (isBackupEnabled(ctx)) BackgroundTaskType.UPLOAD else BackgroundTaskType.LOCAL_SYNC
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx, type)
}
// Re-enqueue itself to listen for future changes
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
return Result.success()
}
private fun isBackupEnabled(context: Context): Boolean {
val prefs =
context.getSharedPreferences(
BackgroundWorkerApiImpl.SHARED_PREF_NAME,
Context.MODE_PRIVATE
)
return prefs.getBoolean(BackgroundWorkerApiImpl.SHARED_PREF_BACKUP_ENABLED, false)
} }
return Result.success()
}
} }
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3012, "android.injected.version.code" => 3011,
"android.injected.version.name" => "1.141.0", "android.injected.version.name" => "1.140.1",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
File diff suppressed because one or more lines are too long
+13
View File
@@ -6,6 +6,9 @@ PODS:
- FlutterMacOS - FlutterMacOS
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
- Flutter - Flutter
- DKImagePickerController/Core (4.3.9): - DKImagePickerController/Core (4.3.9):
@@ -77,6 +80,8 @@ PODS:
- Flutter - Flutter
- network_info_plus (0.0.1): - network_info_plus (0.0.1):
- Flutter - Flutter
- objective_c (0.0.1):
- Flutter
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
@@ -136,6 +141,7 @@ DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`) - background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
@@ -154,6 +160,7 @@ DEPENDENCIES:
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
@@ -184,6 +191,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/bonsoir_darwin/darwin" :path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus: connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios" :path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus: device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios" :path: ".symlinks/plugins/device_info_plus/ios"
file_picker: file_picker:
@@ -220,6 +229,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/native_video_player/ios" :path: ".symlinks/plugins/native_video_player/ios"
network_info_plus: network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios" :path: ".symlinks/plugins/network_info_plus/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation: path_provider_foundation:
@@ -249,6 +260,7 @@ SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
@@ -270,6 +282,7 @@ SPEC CHECKSUMS:
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265 native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
+9 -1
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -507,10 +507,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -539,10 +543,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+1 -1
View File
@@ -24,7 +24,7 @@ import UIKit
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
BackgroundServicePlugin.registerBackgroundProcessing() BackgroundServicePlugin.registerBackgroundProcessing()
BackgroundWorkerApiImpl.registerBackgroundWorkers() BackgroundWorkerApiImpl.registerBackgroundProcessing()
BackgroundServicePlugin.setPluginRegistrantCallback { registry in BackgroundServicePlugin.setPluginRegistrantCallback { registry in
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") { if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
@@ -73,8 +73,9 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi { protocol BackgroundWorkerFgHostApi {
func enable() throws func enableSyncWorker() throws
func disable() throws func enableUploadWorker(callbackHandle: Int64) throws
func disableUploadWorker() throws
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -83,38 +84,52 @@ class BackgroundWorkerFgHostApiSetup {
/// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`. /// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") { static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let enableSyncWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api { if let api = api {
enableChannel.setMessageHandler { _, reply in enableSyncWorkerChannel.setMessageHandler { _, reply in
do { do {
try api.enable() try api.enableSyncWorker()
reply(wrapResult(nil)) reply(wrapResult(nil))
} catch { } catch {
reply(wrapError(error)) reply(wrapError(error))
} }
} }
} else { } else {
enableChannel.setMessageHandler(nil) enableSyncWorkerChannel.setMessageHandler(nil)
} }
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api { if let api = api {
disableChannel.setMessageHandler { _, reply in enableUploadWorkerChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let callbackHandleArg = args[0] as! Int64
do { do {
try api.disable() try api.enableUploadWorker(callbackHandle: callbackHandleArg)
reply(wrapResult(nil)) reply(wrapResult(nil))
} catch { } catch {
reply(wrapError(error)) reply(wrapError(error))
} }
} }
} else { } else {
disableChannel.setMessageHandler(nil) enableUploadWorkerChannel.setMessageHandler(nil)
}
let disableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
disableUploadWorkerChannel.setMessageHandler { _, reply in
do {
try api.disableUploadWorker()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
disableUploadWorkerChannel.setMessageHandler(nil)
} }
} }
} }
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerBgHostApi { protocol BackgroundWorkerBgHostApi {
func onInitialized() throws func onInitialized() throws
func close() throws
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -136,23 +151,11 @@ class BackgroundWorkerBgHostApiSetup {
} else { } else {
onInitializedChannel.setMessageHandler(nil) onInitializedChannel.setMessageHandler(nil)
} }
let closeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
closeChannel.setMessageHandler { _, reply in
do {
try api.close()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
closeChannel.setMessageHandler(nil)
}
} }
} }
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
protocol BackgroundWorkerFlutterApiProtocol { protocol BackgroundWorkerFlutterApiProtocol {
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void) func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void)
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void) func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void)
@@ -167,6 +170,24 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
var codec: BackgroundWorkerPigeonCodec { var codec: BackgroundWorkerPigeonCodec {
return BackgroundWorkerPigeonCodec.shared return BackgroundWorkerPigeonCodec.shared
} }
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
channel.sendMessage([maxSecondsArg] as [Any?]) { response in
guard let listResponse = response as? [Any?] else {
completion(.failure(createConnectionError(withChannelName: channelName)))
return
}
if listResponse.count > 1 {
let code: String = listResponse[0] as! String
let message: String? = nilOrValue(listResponse[1])
let details: String? = nilOrValue(listResponse[2])
completion(.failure(PigeonError(code: code, message: message, details: details)))
} else {
completion(.success(()))
}
}
}
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) { func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)" let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
@@ -1,7 +1,7 @@
import BackgroundTasks import BackgroundTasks
import Flutter import Flutter
enum BackgroundTaskType { case refresh, processing } enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
/* /*
* DEBUG: Testing Background Tasks in Xcode * DEBUG: Testing Background Tasks in Xcode
@@ -9,6 +9,10 @@ enum BackgroundTaskType { case refresh, processing }
* To test background task functionality during development: * To test background task functionality during development:
* 1. Pause the application in Xcode debugger * 1. Pause the application in Xcode debugger
* 2. In the debugger console, enter one of the following commands: * 2. In the debugger console, enter one of the following commands:
## For local sync (short-running sync):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
## For background refresh (short-running sync): ## For background refresh (short-running sync):
@@ -20,6 +24,8 @@ enum BackgroundTaskType { case refresh, processing }
* To simulate task expiration (useful for testing expiration handlers): * To simulate task expiration (useful for testing expiration handlers):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"] e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"] e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
@@ -80,10 +86,28 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* starts the engine, and sets up a timeout timer if specified. * starts the engine, and sets up a timeout timer if specified.
*/ */
func run() { func run() {
// Retrieve the callback handle stored by the main Flutter app
// This handle points to the Flutter function that should be executed in the background
let callbackHandle = Int64(UserDefaults.standard.string(
forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) ?? "0") ?? 0
if callbackHandle == 0 {
// Without a valid callback handle, we cannot start the Flutter background execution
complete(success: false)
return
}
// Use the callback handle to retrieve the actual Flutter callback information
guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else {
// The callback handle is invalid or the callback was not found
complete(success: false)
return
}
// Start the Flutter engine with the specified callback as the entry point // Start the Flutter engine with the specified callback as the entry point
let isRunning = engine.run( let isRunning = engine.run(
withEntrypoint: "backgroundSyncNativeEntrypoint", withEntrypoint: callback.callbackName,
libraryURI: "package:immich_mobile/domain/services/background_worker.service.dart" libraryURI: callback.callbackLibraryPath
) )
// Verify that the Flutter engine started successfully // Verify that the Flutter engine started successfully
@@ -103,7 +127,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
if maxSeconds != nil { if maxSeconds != nil {
// Schedule a timer to cancel the task after the specified timeout period // Schedule a timer to cancel the task after the specified timeout period
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
self.close() self.cancel()
} }
} }
} }
@@ -114,17 +138,25 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* This method acts as a bridge between the native iOS background task system and Flutter. * This method acts as a bridge between the native iOS background task system and Flutter.
*/ */
func onInitialized() throws { func onInitialized() throws {
flutterApi?.onIosUpload(isRefresh: self.taskType == .refresh, maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in switch self.taskType {
self.handleHostResult(result: result) case .refreshUpload, .processingUpload:
}) flutterApi?.onIosUpload(isRefresh: self.taskType == .refreshUpload,
maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
self.handleHostResult(result: result)
})
case .localSync:
flutterApi?.onLocalSync(maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
self.handleHostResult(result: result)
})
}
} }
/** /**
* Cancels the currently running background task, either due to timeout or external request. * Cancels the currently running background task, either due to timeout or external request.
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure * Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
* the completion handler is eventually called even if Flutter doesn't respond. * the completion handler is eventually called even if Flutter doesn't respond.
*/ */
func close() { func cancel() {
if isComplete { if isComplete {
return return
} }
@@ -140,7 +172,6 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
self.complete(success: false) self.complete(success: false)
} }
} }
/** /**
* Handles the result from Flutter API calls and determines the success/failure status. * Handles the result from Flutter API calls and determines the success/failure status.
@@ -151,7 +182,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
private func handleHostResult(result: Result<Void, PigeonError>) { private func handleHostResult(result: Result<Void, PigeonError>) {
switch result { switch result {
case .success(): self.complete(success: true) case .success(): self.complete(success: true)
case .failure(_): self.close() case .failure(_): self.cancel()
} }
} }
@@ -164,10 +195,6 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* - Parameter success: Indicates whether the background task completed successfully * - Parameter success: Indicates whether the background task completed successfully
*/ */
private func complete(success: Bool) { private func complete(success: Bool) {
if(isComplete) {
return
}
isComplete = true isComplete = true
engine.destroyContext() engine.destroyContext()
completionHandler(success) completionHandler(success)
@@ -1,40 +1,84 @@
import BackgroundTasks import BackgroundTasks
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enableSyncWorker() throws {
func enable() throws { BackgroundWorkerApiImpl.scheduleLocalSync()
BackgroundWorkerApiImpl.scheduleRefreshWorker() print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled")
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundUploadImpl:enbale Background worker scheduled")
} }
func disable() throws { func enableUploadWorker(callbackHandle: Int64) throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID); BackgroundWorkerApiImpl.updateUploadEnabled(true)
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID); // Store the callback handle for later use when starting background Flutter isolates
print("BackgroundUploadImpl:disableUploadWorker Disabled background workers") BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle)
BackgroundWorkerApiImpl.scheduleRefreshUpload()
BackgroundWorkerApiImpl.scheduleProcessingUpload()
print("BackgroundUploadImpl:enableUploadWorker Scheduled background upload tasks")
} }
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload" func disableUploadWorker() throws {
private static let processingTaskID = "app.alextran.immich.background.processingUpload" BackgroundWorkerApiImpl.updateUploadEnabled(false)
BackgroundWorkerApiImpl.cancelUploadTasks()
print("BackgroundUploadImpl:disableUploadWorker Disabled background upload tasks")
}
public static let backgroundUploadEnabledKey = "immich:background:backup:enabled"
public static let backgroundUploadCallbackHandleKey = "immich:background:backup:callbackHandle"
private static let localSyncTaskID = "app.alextran.immich.background.localSync"
private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingUploadTaskID = "app.alextran.immich.background.processingUpload"
public static func registerBackgroundWorkers() { private static func updateUploadEnabled(_ isEnabled: Bool) {
return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey)
}
private static func updateUploadCallbackHandle(_ callbackHandle: Int64) {
return UserDefaults.standard.set(String(callbackHandle), forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey)
}
private static func cancelUploadTasks() {
BackgroundWorkerApiImpl.updateUploadEnabled(false)
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID);
}
public static func registerBackgroundProcessing() {
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingTaskID, using: nil) { task in forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in
if task is BGProcessingTask { if task is BGProcessingTask {
handleBackgroundProcessing(task: task as! BGProcessingTask) handleBackgroundProcessing(task: task as! BGProcessingTask)
} }
} }
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshTaskID, using: nil) { task in forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in
if task is BGAppRefreshTask { if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask) handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload)
} }
} }
BGTaskScheduler.shared.register(
forTaskWithIdentifier: localSyncTaskID, using: nil) { task in
if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .localSync)
}
}
}
private static func scheduleLocalSync() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: localSyncTaskID)
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
do {
try BGTaskScheduler.shared.submit(backgroundRefresh)
} catch {
print("Could not schedule the local sync task \(error.localizedDescription)")
}
} }
private static func scheduleRefreshWorker() { private static func scheduleRefreshUpload() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshTaskID) let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID)
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
do { do {
@@ -44,8 +88,8 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
} }
} }
private static func scheduleProcessingWorker() { private static func scheduleProcessingUpload() {
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingTaskID) let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID)
backgroundProcessing.requiresNetworkConnectivity = true backgroundProcessing.requiresNetworkConnectivity = true
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
@@ -57,16 +101,16 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
} }
} }
private static func handleBackgroundRefresh(task: BGAppRefreshTask) { private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) {
scheduleRefreshWorker() scheduleRefreshUpload()
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds // Restrict the refresh task to run only for a maximum of 20 seconds
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20) runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20)
} }
private static func handleBackgroundProcessing(task: BGProcessingTask) { private static func handleBackgroundProcessing(task: BGProcessingTask) {
scheduleProcessingWorker() scheduleProcessingUpload()
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time // There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil) runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil)
} }
/** /**
@@ -90,7 +134,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
task.expirationHandler = { task.expirationHandler = {
DispatchQueue.main.async { DispatchQueue.main.async {
backgroundWorker.close() backgroundWorker.cancel()
} }
isSuccess = false isSuccess = false
@@ -46,23 +46,6 @@ class ThumbnailApiImpl: ThumbnailApi {
assetCache.countLimit = 10000 assetCache.countLimit = 10000
return assetCache return assetCache
}() }()
private static let activitySemaphore = DispatchSemaphore(value: 1)
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.suspend()
activitySemaphore.wait()
}
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.resume()
activitySemaphore.signal()
}
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async { Self.processingQueue.async {
@@ -70,7 +53,6 @@ class ThumbnailApiImpl: ThumbnailApi {
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data) let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)])) completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
} }
} }
@@ -160,7 +142,6 @@ class ThumbnailApiImpl: ThumbnailApi {
return completion(Self.cancelledResult) return completion(Self.cancelledResult)
} }
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)])) completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId) Self.removeRequest(requestId: requestId)
} }
@@ -203,9 +184,4 @@ class ThumbnailApiImpl: ThumbnailApi {
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset return asset
} }
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
} }
+186 -185
View File
@@ -1,189 +1,190 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupId</key> <key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> <string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
<array> <array>
<string>app.alextran.immich.background.refreshUpload</string> <string>app.alextran.immich.background.localSync</string>
<string>app.alextran.immich.background.processingUpload</string> <string>app.alextran.immich.background.refreshUpload</string>
<string>app.alextran.immich.backgroundFetch</string> <string>app.alextran.immich.background.processingUpload</string>
<string>app.alextran.immich.backgroundProcessing</string> <string>app.alextran.immich.backgroundFetch</string>
</array> <string>app.alextran.immich.backgroundProcessing</string>
<key>CADisableMinimumFrameDurationOnPhone</key> </array>
<true/> <key>CADisableMinimumFrameDurationOnPhone</key>
<key>CFBundleDevelopmentRegion</key> <true />
<string>$(DEVELOPMENT_LANGUAGE)</string> <key>CFBundleDevelopmentRegion</key>
<key>CFBundleDisplayName</key> <string>$(DEVELOPMENT_LANGUAGE)</string>
<string>${PRODUCT_NAME}</string> <key>CFBundleDisplayName</key>
<key>CFBundleDocumentTypes</key> <string>${PRODUCT_NAME}</string>
<array> <key>CFBundleDocumentTypes</key>
<dict> <array>
<key>CFBundleTypeName</key> <dict>
<string>ShareHandler</string> <key>CFBundleTypeName</key>
<key>LSHandlerRank</key> <string>ShareHandler</string>
<string>Alternate</string> <key>LSHandlerRank</key>
<key>LSItemContentTypes</key> <string>Alternate</string>
<array> <key>LSItemContentTypes</key>
<string>public.file-url</string> <array>
<string>public.image</string> <string>public.file-url</string>
<string>public.text</string> <string>public.image</string>
<string>public.movie</string> <string>public.text</string>
<string>public.url</string> <string>public.movie</string>
<string>public.data</string> <string>public.url</string>
</array> <string>public.data</string>
</dict> </array>
</array> </dict>
<key>CFBundleExecutable</key> </array>
<string>$(EXECUTABLE_NAME)</string> <key>CFBundleExecutable</key>
<key>CFBundleIdentifier</key> <string>$(EXECUTABLE_NAME)</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleIdentifier</key>
<key>CFBundleInfoDictionaryVersion</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>6.0</string> <key>CFBundleInfoDictionaryVersion</key>
<key>CFBundleLocalizations</key> <string>6.0</string>
<array> <key>CFBundleLocalizations</key>
<string>en</string> <array>
<string>ar</string> <string>en</string>
<string>ca</string> <string>ar</string>
<string>cs</string> <string>ca</string>
<string>da</string> <string>cs</string>
<string>de</string> <string>da</string>
<string>es</string> <string>de</string>
<string>fi</string> <string>es</string>
<string>fr</string> <string>fi</string>
<string>he</string> <string>fr</string>
<string>hi</string> <string>he</string>
<string>hu</string> <string>hi</string>
<string>it</string> <string>hu</string>
<string>ja</string> <string>it</string>
<string>ko</string> <string>ja</string>
<string>lv</string> <string>ko</string>
<string>mn</string> <string>lv</string>
<string>nb</string> <string>mn</string>
<string>nl</string> <string>nb</string>
<string>pl</string> <string>nl</string>
<string>pt</string> <string>pl</string>
<string>ro</string> <string>pt</string>
<string>ru</string> <string>ro</string>
<string>sk</string> <string>ru</string>
<string>sl</string> <string>sk</string>
<string>sr</string> <string>sl</string>
<string>sv</string> <string>sr</string>
<string>th</string> <string>sv</string>
<string>uk</string> <string>th</string>
<string>vi</string> <string>uk</string>
<string>zh</string> <string>vi</string>
</array> <string>zh</string>
<key>CFBundleName</key> </array>
<string>immich_mobile</string> <key>CFBundleName</key>
<key>CFBundlePackageType</key> <string>immich_mobile</string>
<string>APPL</string> <key>CFBundlePackageType</key>
<key>CFBundleShortVersionString</key> <string>APPL</string>
<string>1.140.0</string> <key>CFBundleShortVersionString</key>
<key>CFBundleSignature</key> <string>1.140.0</string>
<string>????</string> <key>CFBundleSignature</key>
<key>CFBundleURLTypes</key> <string>????</string>
<array> <key>CFBundleURLTypes</key>
<dict> <array>
<key>CFBundleTypeRole</key> <dict>
<string>Editor</string> <key>CFBundleTypeRole</key>
<key>CFBundleURLName</key> <string>Editor</string>
<string>Share Extension</string> <key>CFBundleURLName</key>
<key>CFBundleURLSchemes</key> <string>Share Extension</string>
<array> <key>CFBundleURLSchemes</key>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string> <array>
</array> <string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</dict> </array>
<dict> </dict>
<key>CFBundleTypeRole</key> <dict>
<string>Editor</string> <key>CFBundleTypeRole</key>
<key>CFBundleURLName</key> <string>Editor</string>
<string>Deep Link</string> <key>CFBundleURLName</key>
<key>CFBundleURLSchemes</key> <string>Deep Link</string>
<array> <key>CFBundleURLSchemes</key>
<string>immich</string> <array>
</array> <string>immich</string>
</dict> </array>
</array> </dict>
<key>CFBundleVersion</key> </array>
<string>219</string> <key>CFBundleVersion</key>
<key>FLTEnableImpeller</key> <string>219</string>
<true/> <key>FLTEnableImpeller</key>
<key>ITSAppUsesNonExemptEncryption</key> <true />
<false/> <key>ITSAppUsesNonExemptEncryption</key>
<key>LSApplicationQueriesSchemes</key> <false />
<array> <key>LSApplicationQueriesSchemes</key>
<string>https</string> <array>
</array> <string>https</string>
<key>LSRequiresIPhoneOS</key> </array>
<true/> <key>LSRequiresIPhoneOS</key>
<key>LSSupportsOpeningDocumentsInPlace</key> <true />
<string>No</string> <key>LSSupportsOpeningDocumentsInPlace</key>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <string>No</string>
<true/> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<key>NSAppTransportSecurity</key> <true />
<dict> <key>NSAppTransportSecurity</key>
<key>NSAllowsArbitraryLoads</key> <dict>
<true/> <key>NSAllowsArbitraryLoads</key>
</dict> <true />
<key>NSBonjourServices</key> </dict>
<array> <key>NSBonjourServices</key>
<string>_googlecast._tcp</string> <array>
<string>_CC1AD845._googlecast._tcp</string> <string>_googlecast._tcp</string>
</array> <string>_CC1AD845._googlecast._tcp</string>
<key>NSCameraUsageDescription</key> </array>
<string>We need to access the camera to let you take beautiful video using this app</string> <key>NSCameraUsageDescription</key>
<key>NSFaceIDUsageDescription</key> <string>We need to access the camera to let you take beautiful video using this app</string>
<string>We need to use FaceID to allow access to your locked folder</string> <key>NSFaceIDUsageDescription</key>
<key>NSLocalNetworkUsageDescription</key> <string>We need to use FaceID to allow access to your locked folder</string>
<string>We need local network permission to connect to the local server using IP address and <key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string> allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string> <string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key> <key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string> <string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string> <string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string> <string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string> <string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string> <string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>
<array> <array>
<string>INSendMessageIntent</string> <string>INSendMessageIntent</string>
</array> </array>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true />
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>
<string>processing</string> <string>processing</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<false/> <false />
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true />
<key>io.flutter.embedded_views_preview</key> <key>io.flutter.embedded_views_preview</key>
<true/> <true />
</dict> </dict>
</plist> </plist>
+1 -1
View File
@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
) )
increment_version_number( increment_version_number(
version_number: "1.141.0" version_number: "1.140.1"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
@@ -15,7 +15,6 @@ class LocalAlbum {
final int assetCount; final int assetCount;
final BackupSelection backupSelection; final BackupSelection backupSelection;
final String? linkedRemoteAlbumId;
const LocalAlbum({ const LocalAlbum({
required this.id, required this.id,
@@ -24,7 +23,6 @@ class LocalAlbum {
this.assetCount = 0, this.assetCount = 0,
this.backupSelection = BackupSelection.none, this.backupSelection = BackupSelection.none,
this.isIosSharedAlbum = false, this.isIosSharedAlbum = false,
this.linkedRemoteAlbumId,
}); });
LocalAlbum copyWith({ LocalAlbum copyWith({
@@ -34,7 +32,6 @@ class LocalAlbum {
int? assetCount, int? assetCount,
BackupSelection? backupSelection, BackupSelection? backupSelection,
bool? isIosSharedAlbum, bool? isIosSharedAlbum,
String? linkedRemoteAlbumId,
}) { }) {
return LocalAlbum( return LocalAlbum(
id: id ?? this.id, id: id ?? this.id,
@@ -43,7 +40,6 @@ class LocalAlbum {
assetCount: assetCount ?? this.assetCount, assetCount: assetCount ?? this.assetCount,
backupSelection: backupSelection ?? this.backupSelection, backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
); );
} }
@@ -57,8 +53,7 @@ class LocalAlbum {
other.updatedAt == updatedAt && other.updatedAt == updatedAt &&
other.assetCount == assetCount && other.assetCount == assetCount &&
other.backupSelection == backupSelection && other.backupSelection == backupSelection &&
other.isIosSharedAlbum == isIosSharedAlbum && other.isIosSharedAlbum == isIosSharedAlbum;
other.linkedRemoteAlbumId == linkedRemoteAlbumId;
} }
@override @override
@@ -68,8 +63,7 @@ class LocalAlbum {
updatedAt.hashCode ^ updatedAt.hashCode ^
assetCount.hashCode ^ assetCount.hashCode ^
backupSelection.hashCode ^ backupSelection.hashCode ^
isIosSharedAlbum.hashCode ^ isIosSharedAlbum.hashCode;
linkedRemoteAlbumId.hashCode;
} }
@override @override
@@ -81,7 +75,6 @@ updatedAt: $updatedAt,
assetCount: $assetCount, assetCount: $assetCount,
backupSelection: $backupSelection, backupSelection: $backupSelection,
isIosSharedAlbum: $isIosSharedAlbum isIosSharedAlbum: $isIosSharedAlbum
linkedRemoteAlbumId: $linkedRemoteAlbumId,
}'''; }''';
} }
} }
@@ -5,7 +5,6 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_api.g.dart';
@@ -15,7 +14,6 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/localization.service.dart';
@@ -31,9 +29,13 @@ class BackgroundWorkerFgService {
const BackgroundWorkerFgService(this._foregroundHostApi); const BackgroundWorkerFgService(this._foregroundHostApi);
// TODO: Move this call to native side once old timeline is removed // TODO: Move this call to native side once old timeline is removed
Future<void> enable() => _foregroundHostApi.enable(); Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker();
Future<void> disable() => _foregroundHostApi.disable(); Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker(
PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(),
);
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
} }
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@@ -43,7 +45,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final DriftLogger _driftLogger; final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi; final BackgroundWorkerBgHostApi _backgroundHostApi;
final Logger _logger = Logger('BackgroundUploadBgService'); final Logger _logger = Logger('BackgroundUploadBgService');
late final IsolateLockManager _lockManager;
bool _isCleanedUp = false; bool _isCleanedUp = false;
@@ -59,106 +60,98 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
driftProvider.overrideWith(driftOverride(drift)), driftProvider.overrideWith(driftOverride(drift)),
], ],
); );
_lockManager = IsolateLockManager(onCloseRequest: _cleanup);
BackgroundWorkerFlutterApi.setUp(this); BackgroundWorkerFlutterApi.setUp(this);
} }
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
Future<void> init() async { Future<void> init() async {
try { await loadTranslations();
await loadTranslations(); HttpSSLOptions.apply(applyNative: false);
HttpSSLOptions.apply(applyNative: false); await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
// Initialize the file downloader // Initialize the file downloader
await FileDownloader().configure( await FileDownloader().configure(
globalConfig: [ globalConfig: [
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
(Config.holdingQueue, (6, 6, 3)), (Config.holdingQueue, (6, 6, 3)),
// On Android, if files are larger than 256MB, run in foreground service // On Android, if files are larger than 256MB, run in foreground service
(Config.runInForegroundIfFileLargerThan, 256), (Config.runInForegroundIfFileLargerThan, 256),
], ],
); );
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false); await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
await FileDownloader().trackTasks(); await FileDownloader().trackTasks();
configureFileDownloaderNotifications(); configureFileDownloaderNotifications();
await _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();
// Notify the host that the background upload service has been initialized and is ready to use // Notify the host that the background upload service has been initialized and is ready to use
debugPrint("Acquiring background worker lock"); await _backgroundHostApi.onInitialized();
if (await _lockManager.acquireLock().timeout(
const Duration(seconds: 5),
onTimeout: () {
_lockManager.cancel();
return false;
},
)) {
_logger.info("Acquired background worker lock");
await _backgroundHostApi.onInitialized();
return;
}
_logger.warning("Failed to acquire background worker lock");
await _cleanup();
await _backgroundHostApi.close();
} catch (error, stack) {
_logger.severe("Failed to initialize background worker", error, stack);
_backgroundHostApi.close();
}
} }
@override
Future<void> onLocalSync(int? maxSeconds) async {
_logger.info('Local background syncing started');
final sw = Stopwatch()..start();
final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null;
await _syncAssets(hashTimeout: timeout, syncRemote: false);
sw.stop();
_logger.info("Local sync completed in ${sw.elapsed.inSeconds}s");
}
/* We do the following on Android upload
* - Sync local assets
* - Hash local assets 3 / 6 minutes
* - Sync remote assets
* - Check and requeue upload tasks
*/
@override @override
Future<void> onAndroidUpload() async { Future<void> onAndroidUpload() async {
try { _logger.info('Android background processing started');
_logger.info('Android background processing started'); final sw = Stopwatch()..start();
final sw = Stopwatch()..start();
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6)); await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
await _handleBackup(processBulk: false); await _handleBackup(processBulk: false);
sw.stop(); await _cleanup();
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
} catch (error, stack) { sw.stop();
_logger.severe("Failed to complete Android background processing", error, stack); _logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
} finally {
await _cleanup();
}
} }
/* We do the following on background upload
* - Sync local assets
* - Hash local assets
* - Sync remote assets
* - Check and requeue upload tasks
*
* The native side will not send the maxSeconds value for processing tasks
*/
@override @override
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async { Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
try { _logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s'); final sw = Stopwatch()..start();
final sw = Stopwatch()..start();
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6); final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
await _syncAssets(hashTimeout: timeout); await _syncAssets(hashTimeout: timeout);
final backupFuture = _handleBackup(); final backupFuture = _handleBackup();
if (maxSeconds != null) { if (maxSeconds != null) {
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {}); await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
} else { } else {
await backupFuture; await backupFuture;
}
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
} catch (error, stack) {
_logger.severe("Failed to complete iOS background upload", error, stack);
} finally {
await _cleanup();
} }
await _cleanup();
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
} }
@override @override
Future<void> cancel() async { Future<void> cancel() async {
_logger.warning("Background worker cancelled"); _logger.warning("Background upload cancelled");
try { await _cleanup();
await _cleanup();
} catch (error, stack) {
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
}
} }
Future<void> _cleanup() async { Future<void> _cleanup() async {
@@ -166,22 +159,13 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
return; return;
} }
try { _isCleanedUp = true;
_isCleanedUp = true; await _ref.read(backgroundSyncProvider).cancel();
_logger.info("Cleaning up background worker"); await _ref.read(backgroundSyncProvider).cancelLocal();
await _ref.read(backgroundSyncProvider).cancel(); await _isar.close();
await _ref.read(backgroundSyncProvider).cancelLocal(); await _drift.close();
if (_isar.isOpen) { await _driftLogger.close();
await _isar.close(); _ref.dispose();
}
await _drift.close();
await _driftLogger.close();
_ref.dispose();
_lockManager.releaseLock();
_logger.info("Background worker resources cleaned up");
} catch (error, stack) {
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
}
} }
Future<void> _handleBackup({bool processBulk = true}) async { Future<void> _handleBackup({bool processBulk = true}) async {
@@ -206,7 +190,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
} }
} }
Future<void> _syncAssets({Duration? hashTimeout}) async { Future<void> _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async {
final futures = <Future<void>>[]; final futures = <Future<void>>[];
final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async { final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async {
@@ -228,16 +212,17 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}); });
futures.add(localSyncFuture); futures.add(localSyncFuture);
futures.add(_ref.read(backgroundSyncProvider).syncRemote()); if (syncRemote) {
final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote();
futures.add(remoteSyncFuture);
}
await Future.wait(futures); await Future.wait(futures);
} }
} }
/// Native entry invoked from the background worker. If renaming or moving this to a different
/// library, make sure to update the entry points and URI in native workers as well
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> backgroundSyncNativeEntrypoint() async { Future<void> _backgroundSyncNativeEntrypoint() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
+6 -13
View File
@@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -36,7 +35,6 @@ class HashService {
bool get isCancelled => _cancelChecker?.call() ?? false; bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async { Future<void> hashAssets() async {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
// Sorted by backupSelection followed by isCloud // Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getAll( final localAlbums = await _localAlbumRepository.getAll(
@@ -51,7 +49,7 @@ class HashService {
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id); final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) { if (assetsToHash.isNotEmpty) {
await _hashAssets(album, assetsToHash); await _hashAssets(assetsToHash);
} }
} }
@@ -62,7 +60,7 @@ class HashService {
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB /// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table /// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB. /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async { Future<void> _hashAssets(List<LocalAsset> assetsToHash) async {
int bytesProcessed = 0; int bytesProcessed = 0;
final toHash = <_AssetToPath>[]; final toHash = <_AssetToPath>[];
@@ -74,9 +72,6 @@ class HashService {
final file = await _storageRepository.getFileForAsset(asset.id); final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) { if (file == null) {
_log.warning(
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
);
continue; continue;
} }
@@ -84,17 +79,17 @@ class HashService {
toHash.add(_AssetToPath(asset: asset, path: file.path)); toHash.add(_AssetToPath(asset: asset, path: file.path));
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) { if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
await _processBatch(album, toHash); await _processBatch(toHash);
toHash.clear(); toHash.clear();
bytesProcessed = 0; bytesProcessed = 0;
} }
} }
await _processBatch(album, toHash); await _processBatch(toHash);
} }
/// Processes a batch of assets. /// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async { Future<void> _processBatch(List<_AssetToPath> toHash) async {
if (toHash.isEmpty) { if (toHash.isEmpty) {
return; return;
} }
@@ -119,9 +114,7 @@ class HashService {
if (hash?.length == 20) { if (hash?.length == 20) {
hashed.add(asset.copyWith(checksum: base64.encode(hash!))); hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
} else { } else {
_log.warning( _log.warning("Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt}");
"Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
);
} }
} }
@@ -22,16 +22,4 @@ class LocalAlbumService {
Future<int> getCount() { Future<int> getCount() {
return _repository.getCount(); return _repository.getCount();
} }
Future<void> unlinkRemoteAlbum(String id) async {
return _repository.unlinkRemoteAlbum(id);
}
Future<void> linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
return _repository.linkRemoteAlbum(localAlbumId, remoteAlbumId);
}
Future<List<LocalAlbum>> getBackupAlbums() {
return _repository.getBackupAlbums();
}
} }
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
@@ -286,7 +285,7 @@ extension on Iterable<PlatformAlbum> {
(e) => LocalAlbum( (e) => LocalAlbum(
id: e.id, id: e.id,
name: e.name, name: e.name,
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(), updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
assetCount: e.assetCount, assetCount: e.assetCount,
), ),
).toList(); ).toList();
@@ -301,8 +300,8 @@ extension on Iterable<PlatformAsset> {
name: e.name, name: e.name,
checksum: null, checksum: null,
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other, type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
createdAt: tryFromSecondsSinceEpoch(e.createdAt) ?? DateTime.now(), createdAt: e.createdAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000),
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(), updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
width: e.width, width: e.width,
height: e.height, height: e.height,
durationInSeconds: e.durationInSeconds, durationInSeconds: e.durationInSeconds,
@@ -26,10 +26,6 @@ class RemoteAlbumService {
return _repository.get(albumId); return _repository.get(albumId);
} }
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
return _repository.getByName(albumName, ownerId);
}
Future<List<RemoteAlbum>> sortAlbums( Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums, List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, { RemoteAlbumSortMode sortMode, {
@@ -84,6 +80,7 @@ class RemoteAlbumService {
Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async { Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async {
final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds); final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds);
await _repository.create(album, assetIds); await _repository.create(album, assetIds);
return album; return album;
@@ -1,101 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
final syncLinkedAlbumServiceProvider = Provider(
(ref) => SyncLinkedAlbumService(
ref.watch(localAlbumRepository),
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
),
);
class SyncLinkedAlbumService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
const SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository);
Future<void> syncLinkedAlbums(String userId) async {
final selectedAlbums = await _localAlbumRepository.getBackupAlbums();
await Future.wait(
selectedAlbums.map((localAlbum) async {
final linkedRemoteAlbumId = localAlbum.linkedRemoteAlbumId;
if (linkedRemoteAlbumId == null) {
return;
}
final remoteAlbum = await _remoteAlbumRepository.get(linkedRemoteAlbumId);
if (remoteAlbum == null) {
return;
}
// get assets that are uploaded but not in the remote album
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
if (assetIds.isNotEmpty) {
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}
}),
);
}
Future<void> manageLinkedAlbums(List<LocalAlbum> localAlbums, String ownerId) async {
for (final album in localAlbums) {
await _processLocalAlbum(album, ownerId);
}
}
/// Processes a single local album to ensure proper linking with remote albums
Future<void> _processLocalAlbum(LocalAlbum localAlbum, String ownerId) {
final hasLinkedRemoteAlbum = localAlbum.linkedRemoteAlbumId != null;
if (hasLinkedRemoteAlbum) {
return _handleLinkedAlbum(localAlbum);
} else {
return _handleUnlinkedAlbum(localAlbum, ownerId);
}
}
/// Handles albums that are already linked to a remote album
Future<void> _handleLinkedAlbum(LocalAlbum localAlbum) async {
final remoteAlbumId = localAlbum.linkedRemoteAlbumId!;
final remoteAlbum = await _remoteAlbumRepository.get(remoteAlbumId);
final remoteAlbumExists = remoteAlbum != null;
if (!remoteAlbumExists) {
return _localAlbumRepository.unlinkRemoteAlbum(localAlbum.id);
}
}
/// Handles albums that are not linked to any remote album
Future<void> _handleUnlinkedAlbum(LocalAlbum localAlbum, String ownerId) async {
final existingRemoteAlbum = await _remoteAlbumRepository.getByName(localAlbum.name, ownerId);
if (existingRemoteAlbum != null) {
return _linkToExistingRemoteAlbum(localAlbum, existingRemoteAlbum);
} else {
return _createAndLinkNewRemoteAlbum(localAlbum);
}
}
/// Links a local album to an existing remote album
Future<void> _linkToExistingRemoteAlbum(LocalAlbum localAlbum, dynamic existingRemoteAlbum) {
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, existingRemoteAlbum.id);
}
/// Creates a new remote album and links it to the local album
Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async {
debugPrint("Creating new remote album for local album: ${localAlbum.name}");
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, assetIds: []);
await _remoteAlbumRepository.create(newRemoteAlbum, []);
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
}
}
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart'; import 'package:immich_mobile/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart'; import 'package:worker_manager/worker_manager.dart';
@@ -156,11 +155,6 @@ class BackgroundSyncManager {
_syncWebsocketTask = null; _syncWebsocketTask = null;
}); });
} }
Future<void> syncLinkedAlbum() {
final task = runInIsolateGentle(computation: syncLinkedAlbumsIsolated);
return task.future;
}
} }
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle( Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
@@ -1,235 +0,0 @@
import 'dart:isolate';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
const String kIsolateLockManagerPort = "immich://isolate_mutex";
enum _LockStatus { active, released }
class _IsolateRequest {
const _IsolateRequest();
}
class _HeartbeatRequest extends _IsolateRequest {
// Port for the receiver to send replies back
final SendPort sendPort;
const _HeartbeatRequest(this.sendPort);
Map<String, dynamic> toJson() {
return {'type': 'heartbeat', 'sendPort': sendPort};
}
}
class _CloseRequest extends _IsolateRequest {
const _CloseRequest();
Map<String, dynamic> toJson() {
return {'type': 'close'};
}
}
class _IsolateResponse {
const _IsolateResponse();
}
class _HeartbeatResponse extends _IsolateResponse {
final _LockStatus status;
const _HeartbeatResponse(this.status);
Map<String, dynamic> toJson() {
return {'type': 'heartbeat', 'status': status.index};
}
}
typedef OnCloseLockHolderRequest = void Function();
class IsolateLockManager {
final String _portName;
bool _hasLock = false;
ReceivePort? _receivePort;
final OnCloseLockHolderRequest? _onCloseRequest;
final Set<SendPort> _waitingIsolates = {};
// Token object - a new one is created for each acquisition attempt
Object? _currentAcquisitionToken;
IsolateLockManager({String? portName, OnCloseLockHolderRequest? onCloseRequest})
: _portName = portName ?? kIsolateLockManagerPort,
_onCloseRequest = onCloseRequest;
Future<bool> acquireLock() async {
if (_hasLock) {
Logger('BackgroundWorkerLockManager').warning("WARNING: [acquireLock] called more than once");
return true;
}
// Create a new token - this invalidates any previous attempt
final token = _currentAcquisitionToken = Object();
final ReceivePort rp = _receivePort = ReceivePort(_portName);
final SendPort sp = rp.sendPort;
while (!IsolateNameServer.registerPortWithName(sp, _portName)) {
// This attempt was superseded by a newer one in the same isolate
if (_currentAcquisitionToken != token) {
return false;
}
await _lockReleasedByHolder(token);
}
_hasLock = true;
rp.listen(_onRequest);
return true;
}
Future<void> _lockReleasedByHolder(Object token) async {
SendPort? holder = IsolateNameServer.lookupPortByName(_portName);
debugPrint("Found lock holder: $holder");
if (holder == null) {
// No holder, try and acquire lock
return;
}
final ReceivePort tempRp = ReceivePort();
final SendPort tempSp = tempRp.sendPort;
final bs = tempRp.asBroadcastStream();
try {
while (true) {
// Send a heartbeat request with the send port to receive reply from the holder
debugPrint("Sending heartbeat request to lock holder");
holder.send(_HeartbeatRequest(tempSp).toJson());
dynamic answer = await bs.first.timeout(const Duration(seconds: 3), onTimeout: () => null);
debugPrint("Received heartbeat response from lock holder: $answer");
// This attempt was superseded by a newer one in the same isolate
if (_currentAcquisitionToken != token) {
break;
}
if (answer == null) {
// Holder failed, most likely killed without calling releaseLock
// Check if a different waiting isolate took the lock
if (holder == IsolateNameServer.lookupPortByName(_portName)) {
// No, remove the stale lock
IsolateNameServer.removePortNameMapping(_portName);
}
break;
}
// Unknown message type received for heartbeat request. Try again
_IsolateResponse? response = _parseResponse(answer);
if (response == null || response is! _HeartbeatResponse) {
break;
}
if (response.status == _LockStatus.released) {
// Holder has released the lock
break;
}
// If the _LockStatus is active, we check again if the task completed
// by sending a released messaged again, if not, send a new heartbeat again
// Check if the holder completed its task after the heartbeat
answer = await bs.first.timeout(
const Duration(seconds: 3),
onTimeout: () => const _HeartbeatResponse(_LockStatus.active).toJson(),
);
response = _parseResponse(answer);
if (response is _HeartbeatResponse && response.status == _LockStatus.released) {
break;
}
}
} catch (e) {
// Timeout or error
} finally {
tempRp.close();
}
return;
}
_IsolateRequest? _parseRequest(dynamic msg) {
if (msg is! Map<String, dynamic>) {
return null;
}
return switch (msg['type']) {
'heartbeat' => _HeartbeatRequest(msg['sendPort']),
'close' => const _CloseRequest(),
_ => null,
};
}
_IsolateResponse? _parseResponse(dynamic msg) {
if (msg is! Map<String, dynamic>) {
return null;
}
return switch (msg['type']) {
'heartbeat' => _HeartbeatResponse(_LockStatus.values[msg['status']]),
_ => null,
};
}
// Executed in the isolate with the lock
void _onRequest(dynamic msg) {
final request = _parseRequest(msg);
if (request == null) {
return;
}
if (request is _HeartbeatRequest) {
// Add the send port to the list of waiting isolates
_waitingIsolates.add(request.sendPort);
request.sendPort.send(const _HeartbeatResponse(_LockStatus.active).toJson());
return;
}
if (request is _CloseRequest) {
_onCloseRequest?.call();
return;
}
}
void releaseLock() {
if (_hasLock) {
IsolateNameServer.removePortNameMapping(_portName);
// Notify waiting isolates
for (final port in _waitingIsolates) {
port.send(const _HeartbeatResponse(_LockStatus.released).toJson());
}
_waitingIsolates.clear();
_hasLock = false;
}
_receivePort?.close();
_receivePort = null;
}
void cancel() {
if (_hasLock) {
return;
}
debugPrint("Cancelling ongoing acquire lock attempts");
// Create a new token to invalidate ongoing acquire lock attempts
_currentAcquisitionToken = Object();
}
void requestHolderToClose() {
if (_hasLock) {
return;
}
IsolateNameServer.lookupPortByName(_portName)?.send(const _CloseRequest().toJson());
}
}
@@ -1,11 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
final user = ref.read(currentUserProvider);
if (user == null) {
return Future.value();
}
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
}
@@ -1,7 +1,5 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class LocalAlbumEntity extends Table with DriftDefaultsMixin { class LocalAlbumEntity extends Table with DriftDefaultsMixin {
@@ -13,26 +11,9 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin {
IntColumn get backupSelection => intEnum<BackupSelection>()(); IntColumn get backupSelection => intEnum<BackupSelection>()();
BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))(); BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))();
// // Linked album for putting assets to the remote album after finished uploading
TextColumn get linkedRemoteAlbumId =>
text().references(RemoteAlbumEntity, #id, onDelete: KeyAction.setNull).nullable()();
// Used for mark & sweep // Used for mark & sweep
BoolColumn get marker_ => boolean().nullable()(); BoolColumn get marker_ => boolean().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum(
id: id,
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
backupSelection: backupSelection,
linkedRemoteAlbumId: linkedRemoteAlbumId,
);
}
}
+20 -253
View File
@@ -7,9 +7,6 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart' import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'
as i3; as i3;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4; import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i5;
import 'package:drift/internal/modular.dart' as i6;
typedef $$LocalAlbumEntityTableCreateCompanionBuilder = typedef $$LocalAlbumEntityTableCreateCompanionBuilder =
i1.LocalAlbumEntityCompanion Function({ i1.LocalAlbumEntityCompanion Function({
@@ -18,7 +15,6 @@ typedef $$LocalAlbumEntityTableCreateCompanionBuilder =
i0.Value<DateTime> updatedAt, i0.Value<DateTime> updatedAt,
required i2.BackupSelection backupSelection, required i2.BackupSelection backupSelection,
i0.Value<bool> isIosSharedAlbum, i0.Value<bool> isIosSharedAlbum,
i0.Value<String?> linkedRemoteAlbumId,
i0.Value<bool?> marker_, i0.Value<bool?> marker_,
}); });
typedef $$LocalAlbumEntityTableUpdateCompanionBuilder = typedef $$LocalAlbumEntityTableUpdateCompanionBuilder =
@@ -28,57 +24,9 @@ typedef $$LocalAlbumEntityTableUpdateCompanionBuilder =
i0.Value<DateTime> updatedAt, i0.Value<DateTime> updatedAt,
i0.Value<i2.BackupSelection> backupSelection, i0.Value<i2.BackupSelection> backupSelection,
i0.Value<bool> isIosSharedAlbum, i0.Value<bool> isIosSharedAlbum,
i0.Value<String?> linkedRemoteAlbumId,
i0.Value<bool?> marker_, i0.Value<bool?> marker_,
}); });
final class $$LocalAlbumEntityTableReferences
extends
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData
> {
$$LocalAlbumEntityTableReferences(
super.$_db,
super.$_table,
super.$_typedResult,
);
static i5.$RemoteAlbumEntityTable _linkedRemoteAlbumIdTable(
i0.GeneratedDatabase db,
) => i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$LocalAlbumEntityTable>('local_album_entity')
.linkedRemoteAlbumId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity').id,
),
);
i5.$$RemoteAlbumEntityTableProcessedTableManager? get linkedRemoteAlbumId {
final $_column = $_itemColumn<String>('linked_remote_album_id');
if ($_column == null) return null;
final manager = i5
.$$RemoteAlbumEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer(
$_db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_linkedRemoteAlbumIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
}
class $$LocalAlbumEntityTableFilterComposer class $$LocalAlbumEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable> { extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable> {
$$LocalAlbumEntityTableFilterComposer({ $$LocalAlbumEntityTableFilterComposer({
@@ -118,33 +66,6 @@ class $$LocalAlbumEntityTableFilterComposer
column: $table.marker_, column: $table.marker_,
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
); );
i5.$$RemoteAlbumEntityTableFilterComposer get linkedRemoteAlbumId {
final i5.$$RemoteAlbumEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.linkedRemoteAlbumId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAlbumEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
} }
class $$LocalAlbumEntityTableOrderingComposer class $$LocalAlbumEntityTableOrderingComposer
@@ -185,34 +106,6 @@ class $$LocalAlbumEntityTableOrderingComposer
column: $table.marker_, column: $table.marker_,
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i5.$$RemoteAlbumEntityTableOrderingComposer get linkedRemoteAlbumId {
final i5.$$RemoteAlbumEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.linkedRemoteAlbumId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAlbumEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
} }
class $$LocalAlbumEntityTableAnnotationComposer class $$LocalAlbumEntityTableAnnotationComposer
@@ -246,34 +139,6 @@ class $$LocalAlbumEntityTableAnnotationComposer
i0.GeneratedColumn<bool> get marker_ => i0.GeneratedColumn<bool> get marker_ =>
$composableBuilder(column: $table.marker_, builder: (column) => column); $composableBuilder(column: $table.marker_, builder: (column) => column);
i5.$$RemoteAlbumEntityTableAnnotationComposer get linkedRemoteAlbumId {
final i5.$$RemoteAlbumEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.linkedRemoteAlbumId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAlbumEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
} }
class $$LocalAlbumEntityTableTableManager class $$LocalAlbumEntityTableTableManager
@@ -287,9 +152,16 @@ class $$LocalAlbumEntityTableTableManager
i1.$$LocalAlbumEntityTableAnnotationComposer, i1.$$LocalAlbumEntityTableAnnotationComposer,
$$LocalAlbumEntityTableCreateCompanionBuilder, $$LocalAlbumEntityTableCreateCompanionBuilder,
$$LocalAlbumEntityTableUpdateCompanionBuilder, $$LocalAlbumEntityTableUpdateCompanionBuilder,
(i1.LocalAlbumEntityData, i1.$$LocalAlbumEntityTableReferences), (
i1.LocalAlbumEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData
>,
),
i1.LocalAlbumEntityData, i1.LocalAlbumEntityData,
i0.PrefetchHooks Function({bool linkedRemoteAlbumId}) i0.PrefetchHooks Function()
> { > {
$$LocalAlbumEntityTableTableManager( $$LocalAlbumEntityTableTableManager(
i0.GeneratedDatabase db, i0.GeneratedDatabase db,
@@ -315,7 +187,6 @@ class $$LocalAlbumEntityTableTableManager
i0.Value<i2.BackupSelection> backupSelection = i0.Value<i2.BackupSelection> backupSelection =
const i0.Value.absent(), const i0.Value.absent(),
i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(), i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(),
i0.Value<String?> linkedRemoteAlbumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(), i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumEntityCompanion( }) => i1.LocalAlbumEntityCompanion(
id: id, id: id,
@@ -323,7 +194,6 @@ class $$LocalAlbumEntityTableTableManager
updatedAt: updatedAt, updatedAt: updatedAt,
backupSelection: backupSelection, backupSelection: backupSelection,
isIosSharedAlbum: isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId,
marker_: marker_, marker_: marker_,
), ),
createCompanionCallback: createCompanionCallback:
@@ -333,7 +203,6 @@ class $$LocalAlbumEntityTableTableManager
i0.Value<DateTime> updatedAt = const i0.Value.absent(), i0.Value<DateTime> updatedAt = const i0.Value.absent(),
required i2.BackupSelection backupSelection, required i2.BackupSelection backupSelection,
i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(), i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(),
i0.Value<String?> linkedRemoteAlbumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(), i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumEntityCompanion.insert( }) => i1.LocalAlbumEntityCompanion.insert(
id: id, id: id,
@@ -341,60 +210,12 @@ class $$LocalAlbumEntityTableTableManager
updatedAt: updatedAt, updatedAt: updatedAt,
backupSelection: backupSelection, backupSelection: backupSelection,
isIosSharedAlbum: isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId,
marker_: marker_, marker_: marker_,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
.map( .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
(e) => (
e.readTable(table),
i1.$$LocalAlbumEntityTableReferences(db, table, e),
),
)
.toList(), .toList(),
prefetchHooksCallback: ({linkedRemoteAlbumId = false}) { prefetchHooksCallback: null,
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins:
<
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic
>
>(state) {
if (linkedRemoteAlbumId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.linkedRemoteAlbumId,
referencedTable: i1
.$$LocalAlbumEntityTableReferences
._linkedRemoteAlbumIdTable(db),
referencedColumn: i1
.$$LocalAlbumEntityTableReferences
._linkedRemoteAlbumIdTable(db)
.id,
)
as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
), ),
); );
} }
@@ -409,9 +230,16 @@ typedef $$LocalAlbumEntityTableProcessedTableManager =
i1.$$LocalAlbumEntityTableAnnotationComposer, i1.$$LocalAlbumEntityTableAnnotationComposer,
$$LocalAlbumEntityTableCreateCompanionBuilder, $$LocalAlbumEntityTableCreateCompanionBuilder,
$$LocalAlbumEntityTableUpdateCompanionBuilder, $$LocalAlbumEntityTableUpdateCompanionBuilder,
(i1.LocalAlbumEntityData, i1.$$LocalAlbumEntityTableReferences), (
i1.LocalAlbumEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData
>,
),
i1.LocalAlbumEntityData, i1.LocalAlbumEntityData,
i0.PrefetchHooks Function({bool linkedRemoteAlbumId}) i0.PrefetchHooks Function()
>; >;
class $LocalAlbumEntityTable extends i3.LocalAlbumEntity class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
@@ -480,20 +308,6 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
), ),
defaultValue: const i4.Constant(false), defaultValue: const i4.Constant(false),
); );
static const i0.VerificationMeta _linkedRemoteAlbumIdMeta =
const i0.VerificationMeta('linkedRemoteAlbumId');
@override
late final i0.GeneratedColumn<String> linkedRemoteAlbumId =
i0.GeneratedColumn<String>(
'linked_remote_album_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_album_entity (id) ON DELETE SET NULL',
),
);
static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta( static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta(
'marker_', 'marker_',
); );
@@ -515,7 +329,6 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
updatedAt, updatedAt,
backupSelection, backupSelection,
isIosSharedAlbum, isIosSharedAlbum,
linkedRemoteAlbumId,
marker_, marker_,
]; ];
@override @override
@@ -558,15 +371,6 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
), ),
); );
} }
if (data.containsKey('linked_remote_album_id')) {
context.handle(
_linkedRemoteAlbumIdMeta,
linkedRemoteAlbumId.isAcceptableOrUnknown(
data['linked_remote_album_id']!,
_linkedRemoteAlbumIdMeta,
),
);
}
if (data.containsKey('marker')) { if (data.containsKey('marker')) {
context.handle( context.handle(
_marker_Meta, _marker_Meta,
@@ -608,10 +412,6 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
i0.DriftSqlType.bool, i0.DriftSqlType.bool,
data['${effectivePrefix}is_ios_shared_album'], data['${effectivePrefix}is_ios_shared_album'],
)!, )!,
linkedRemoteAlbumId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}linked_remote_album_id'],
),
marker_: attachedDatabase.typeMapping.read( marker_: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool, i0.DriftSqlType.bool,
data['${effectivePrefix}marker'], data['${effectivePrefix}marker'],
@@ -641,7 +441,6 @@ class LocalAlbumEntityData extends i0.DataClass
final DateTime updatedAt; final DateTime updatedAt;
final i2.BackupSelection backupSelection; final i2.BackupSelection backupSelection;
final bool isIosSharedAlbum; final bool isIosSharedAlbum;
final String? linkedRemoteAlbumId;
final bool? marker_; final bool? marker_;
const LocalAlbumEntityData({ const LocalAlbumEntityData({
required this.id, required this.id,
@@ -649,7 +448,6 @@ class LocalAlbumEntityData extends i0.DataClass
required this.updatedAt, required this.updatedAt,
required this.backupSelection, required this.backupSelection,
required this.isIosSharedAlbum, required this.isIosSharedAlbum,
this.linkedRemoteAlbumId,
this.marker_, this.marker_,
}); });
@override @override
@@ -666,9 +464,6 @@ class LocalAlbumEntityData extends i0.DataClass
); );
} }
map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum); map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum);
if (!nullToAbsent || linkedRemoteAlbumId != null) {
map['linked_remote_album_id'] = i0.Variable<String>(linkedRemoteAlbumId);
}
if (!nullToAbsent || marker_ != null) { if (!nullToAbsent || marker_ != null) {
map['marker'] = i0.Variable<bool>(marker_); map['marker'] = i0.Variable<bool>(marker_);
} }
@@ -687,9 +482,6 @@ class LocalAlbumEntityData extends i0.DataClass
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
.fromJson(serializer.fromJson<int>(json['backupSelection'])), .fromJson(serializer.fromJson<int>(json['backupSelection'])),
isIosSharedAlbum: serializer.fromJson<bool>(json['isIosSharedAlbum']), isIosSharedAlbum: serializer.fromJson<bool>(json['isIosSharedAlbum']),
linkedRemoteAlbumId: serializer.fromJson<String?>(
json['linkedRemoteAlbumId'],
),
marker_: serializer.fromJson<bool?>(json['marker_']), marker_: serializer.fromJson<bool?>(json['marker_']),
); );
} }
@@ -706,7 +498,6 @@ class LocalAlbumEntityData extends i0.DataClass
), ),
), ),
'isIosSharedAlbum': serializer.toJson<bool>(isIosSharedAlbum), 'isIosSharedAlbum': serializer.toJson<bool>(isIosSharedAlbum),
'linkedRemoteAlbumId': serializer.toJson<String?>(linkedRemoteAlbumId),
'marker_': serializer.toJson<bool?>(marker_), 'marker_': serializer.toJson<bool?>(marker_),
}; };
} }
@@ -717,7 +508,6 @@ class LocalAlbumEntityData extends i0.DataClass
DateTime? updatedAt, DateTime? updatedAt,
i2.BackupSelection? backupSelection, i2.BackupSelection? backupSelection,
bool? isIosSharedAlbum, bool? isIosSharedAlbum,
i0.Value<String?> linkedRemoteAlbumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(), i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumEntityData( }) => i1.LocalAlbumEntityData(
id: id ?? this.id, id: id ?? this.id,
@@ -725,9 +515,6 @@ class LocalAlbumEntityData extends i0.DataClass
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
backupSelection: backupSelection ?? this.backupSelection, backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId.present
? linkedRemoteAlbumId.value
: this.linkedRemoteAlbumId,
marker_: marker_.present ? marker_.value : this.marker_, marker_: marker_.present ? marker_.value : this.marker_,
); );
LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) { LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) {
@@ -741,9 +528,6 @@ class LocalAlbumEntityData extends i0.DataClass
isIosSharedAlbum: data.isIosSharedAlbum.present isIosSharedAlbum: data.isIosSharedAlbum.present
? data.isIosSharedAlbum.value ? data.isIosSharedAlbum.value
: this.isIosSharedAlbum, : this.isIosSharedAlbum,
linkedRemoteAlbumId: data.linkedRemoteAlbumId.present
? data.linkedRemoteAlbumId.value
: this.linkedRemoteAlbumId,
marker_: data.marker_.present ? data.marker_.value : this.marker_, marker_: data.marker_.present ? data.marker_.value : this.marker_,
); );
} }
@@ -756,7 +540,6 @@ class LocalAlbumEntityData extends i0.DataClass
..write('updatedAt: $updatedAt, ') ..write('updatedAt: $updatedAt, ')
..write('backupSelection: $backupSelection, ') ..write('backupSelection: $backupSelection, ')
..write('isIosSharedAlbum: $isIosSharedAlbum, ') ..write('isIosSharedAlbum: $isIosSharedAlbum, ')
..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ')
..write('marker_: $marker_') ..write('marker_: $marker_')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -769,7 +552,6 @@ class LocalAlbumEntityData extends i0.DataClass
updatedAt, updatedAt,
backupSelection, backupSelection,
isIosSharedAlbum, isIosSharedAlbum,
linkedRemoteAlbumId,
marker_, marker_,
); );
@override @override
@@ -781,7 +563,6 @@ class LocalAlbumEntityData extends i0.DataClass
other.updatedAt == this.updatedAt && other.updatedAt == this.updatedAt &&
other.backupSelection == this.backupSelection && other.backupSelection == this.backupSelection &&
other.isIosSharedAlbum == this.isIosSharedAlbum && other.isIosSharedAlbum == this.isIosSharedAlbum &&
other.linkedRemoteAlbumId == this.linkedRemoteAlbumId &&
other.marker_ == this.marker_); other.marker_ == this.marker_);
} }
@@ -792,7 +573,6 @@ class LocalAlbumEntityCompanion
final i0.Value<DateTime> updatedAt; final i0.Value<DateTime> updatedAt;
final i0.Value<i2.BackupSelection> backupSelection; final i0.Value<i2.BackupSelection> backupSelection;
final i0.Value<bool> isIosSharedAlbum; final i0.Value<bool> isIosSharedAlbum;
final i0.Value<String?> linkedRemoteAlbumId;
final i0.Value<bool?> marker_; final i0.Value<bool?> marker_;
const LocalAlbumEntityCompanion({ const LocalAlbumEntityCompanion({
this.id = const i0.Value.absent(), this.id = const i0.Value.absent(),
@@ -800,7 +580,6 @@ class LocalAlbumEntityCompanion
this.updatedAt = const i0.Value.absent(), this.updatedAt = const i0.Value.absent(),
this.backupSelection = const i0.Value.absent(), this.backupSelection = const i0.Value.absent(),
this.isIosSharedAlbum = const i0.Value.absent(), this.isIosSharedAlbum = const i0.Value.absent(),
this.linkedRemoteAlbumId = const i0.Value.absent(),
this.marker_ = const i0.Value.absent(), this.marker_ = const i0.Value.absent(),
}); });
LocalAlbumEntityCompanion.insert({ LocalAlbumEntityCompanion.insert({
@@ -809,7 +588,6 @@ class LocalAlbumEntityCompanion
this.updatedAt = const i0.Value.absent(), this.updatedAt = const i0.Value.absent(),
required i2.BackupSelection backupSelection, required i2.BackupSelection backupSelection,
this.isIosSharedAlbum = const i0.Value.absent(), this.isIosSharedAlbum = const i0.Value.absent(),
this.linkedRemoteAlbumId = const i0.Value.absent(),
this.marker_ = const i0.Value.absent(), this.marker_ = const i0.Value.absent(),
}) : id = i0.Value(id), }) : id = i0.Value(id),
name = i0.Value(name), name = i0.Value(name),
@@ -820,7 +598,6 @@ class LocalAlbumEntityCompanion
i0.Expression<DateTime>? updatedAt, i0.Expression<DateTime>? updatedAt,
i0.Expression<int>? backupSelection, i0.Expression<int>? backupSelection,
i0.Expression<bool>? isIosSharedAlbum, i0.Expression<bool>? isIosSharedAlbum,
i0.Expression<String>? linkedRemoteAlbumId,
i0.Expression<bool>? marker_, i0.Expression<bool>? marker_,
}) { }) {
return i0.RawValuesInsertable({ return i0.RawValuesInsertable({
@@ -829,8 +606,6 @@ class LocalAlbumEntityCompanion
if (updatedAt != null) 'updated_at': updatedAt, if (updatedAt != null) 'updated_at': updatedAt,
if (backupSelection != null) 'backup_selection': backupSelection, if (backupSelection != null) 'backup_selection': backupSelection,
if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum,
if (linkedRemoteAlbumId != null)
'linked_remote_album_id': linkedRemoteAlbumId,
if (marker_ != null) 'marker': marker_, if (marker_ != null) 'marker': marker_,
}); });
} }
@@ -841,7 +616,6 @@ class LocalAlbumEntityCompanion
i0.Value<DateTime>? updatedAt, i0.Value<DateTime>? updatedAt,
i0.Value<i2.BackupSelection>? backupSelection, i0.Value<i2.BackupSelection>? backupSelection,
i0.Value<bool>? isIosSharedAlbum, i0.Value<bool>? isIosSharedAlbum,
i0.Value<String?>? linkedRemoteAlbumId,
i0.Value<bool?>? marker_, i0.Value<bool?>? marker_,
}) { }) {
return i1.LocalAlbumEntityCompanion( return i1.LocalAlbumEntityCompanion(
@@ -850,7 +624,6 @@ class LocalAlbumEntityCompanion
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
backupSelection: backupSelection ?? this.backupSelection, backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
marker_: marker_ ?? this.marker_, marker_: marker_ ?? this.marker_,
); );
} }
@@ -877,11 +650,6 @@ class LocalAlbumEntityCompanion
if (isIosSharedAlbum.present) { if (isIosSharedAlbum.present) {
map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum.value); map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum.value);
} }
if (linkedRemoteAlbumId.present) {
map['linked_remote_album_id'] = i0.Variable<String>(
linkedRemoteAlbumId.value,
);
}
if (marker_.present) { if (marker_.present) {
map['marker'] = i0.Variable<bool>(marker_.value); map['marker'] = i0.Variable<bool>(marker_.value);
} }
@@ -896,7 +664,6 @@ class LocalAlbumEntityCompanion
..write('updatedAt: $updatedAt, ') ..write('updatedAt: $updatedAt, ')
..write('backupSelection: $backupSelection, ') ..write('backupSelection: $backupSelection, ')
..write('isIosSharedAlbum: $isIosSharedAlbum, ') ..write('isIosSharedAlbum: $isIosSharedAlbum, ')
..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ')
..write('marker_: $marker_') ..write('marker_: $marker_')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -20,7 +20,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData { extension LocalAssetEntityDataDomainEx on LocalAssetEntityData {
LocalAsset toDto() => LocalAsset( LocalAsset toDto() => LocalAsset(
id: id, id: id,
name: name, name: name,
@@ -1,15 +1,16 @@
import 'dart:async'; import 'dart:async';
import 'dart:ffi'; import 'dart:ffi';
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cronet_http/cronet_http.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:logging/logging.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
part 'local_image_request.dart'; part 'local_image_request.dart';
part 'thumbhash_image_request.dart'; part 'thumbhash_image_request.dart';
@@ -1,14 +1,18 @@
part of 'image_request.dart'; part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest { class RemoteImageRequest extends ImageRequest {
static final log = Logger('RemoteImageRequest'); static final _client = const NetworkRepository().getHttpClient(
static final client = HttpClient()..maxConnectionsPerHost = 16; 'thumbnails',
final RemoteCacheManager? cacheManager; diskCapacity: kThumbnailDiskCacheSize,
memoryCapacity: 0,
maxConnections: 16,
cacheMode: CacheMode.disk,
);
final String uri; final String uri;
final Map<String, String> headers; final Map<String, String> headers;
HttpClientRequest? _request; final abortTrigger = Completer<void>();
RemoteImageRequest({required this.uri, required this.headers, this.cacheManager}); RemoteImageRequest({required this.uri, required this.headers});
@override @override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async { Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@@ -16,15 +20,8 @@ class RemoteImageRequest extends ImageRequest {
return null; return null;
} }
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
// so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call.
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
if (cachedFileImage != null) {
return cachedFileImage;
}
try { try {
final buffer = await _downloadImage(uri); final buffer = await _downloadImage();
if (buffer == null) { if (buffer == null) {
return null; return null;
} }
@@ -35,57 +32,41 @@ class RemoteImageRequest extends ImageRequest {
return null; return null;
} }
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
if (cachedFileImage != null) {
return cachedFileImage;
}
rethrow; rethrow;
} finally {
_request = null;
} }
} }
Future<ImmutableBuffer?> _downloadImage(String url) async { Future<ImmutableBuffer?> _downloadImage() async {
if (_isCancelled) { if (_isCancelled) {
return null; return null;
} }
final request = _request = await client.getUrl(Uri.parse(url)); final req = http.AbortableRequest('GET', Uri.parse(uri), abortTrigger: abortTrigger.future);
if (_isCancelled) { req.headers.addAll(headers);
request.abort(); final res = await _client.send(req);
return _request = null;
}
for (final entry in headers.entries) {
request.headers.set(entry.key, entry.value);
}
final response = await request.close();
if (_isCancelled) { if (_isCancelled) {
_onCancelled();
return null; return null;
} }
final cacheManager = this.cacheManager; if (res.statusCode != 200) {
final streamController = StreamController<List<int>>(sync: true); throw Exception('Failed to download $uri: ${res.statusCode}');
final Stream<List<int>> stream; }
cacheManager?.putStreamedFile(url, streamController.stream);
stream = response.map((chunk) { final stream = res.stream.map((chunk) {
if (_isCancelled) { if (_isCancelled) {
throw StateError('Cancelled request'); throw StateError('Cancelled request');
} }
if (cacheManager != null) {
streamController.add(chunk);
}
return chunk; return chunk;
}); });
try { try {
final Uint8List bytes = await _downloadBytes(stream, response.contentLength); final Uint8List bytes = await _downloadBytes(stream, res.contentLength ?? -1);
streamController.close(); if (_isCancelled) {
return null;
}
return await ImmutableBuffer.fromUint8List(bytes); return await ImmutableBuffer.fromUint8List(bytes);
} catch (e) { } catch (e) {
streamController.addError(e);
streamController.close();
if (_isCancelled) { if (_isCancelled) {
return null; return null;
} }
@@ -122,40 +103,6 @@ class RemoteImageRequest extends ImageRequest {
return bytes; return bytes;
} }
Future<ImageInfo?> _loadCachedFile(
String url,
ImageDecoderCallback decode,
double scale, {
required bool inMemoryOnly,
}) async {
final cacheManager = this.cacheManager;
if (_isCancelled || cacheManager == null) {
return null;
}
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
if (_isCancelled || file == null) {
return null;
}
try {
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
log.severe('Failed to decode cached image', e);
_evictFile(url);
return null;
}
}
Future<void> _evictFile(String url) async {
try {
await cacheManager?.removeFile(url);
} catch (e) {
log.severe('Failed to remove cached image', e);
}
}
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async { Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
if (_isCancelled) { if (_isCancelled) {
buffer.dispose(); buffer.dispose();
@@ -173,7 +120,6 @@ class RemoteImageRequest extends ImageRequest {
@override @override
void _onCancelled() { void _onCancelled() {
_request?.abort(); abortTrigger.complete();
_request = null;
} }
} }
@@ -4,10 +4,9 @@ import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import "package:immich_mobile/utils/database.utils.dart";
final backupRepositoryProvider = Provider<DriftBackupRepository>( final backupRepositoryProvider = Provider<DriftBackupRepository>(
(ref) => DriftBackupRepository(ref.watch(driftProvider)), (ref) => DriftBackupRepository(ref.watch(driftProvider)),
@@ -68,7 +68,7 @@ class Drift extends $Drift implements IDatabaseRepository {
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true))); : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
@override @override
int get schemaVersion => 9; int get schemaVersion => 8;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -123,9 +123,6 @@ class Drift extends $Drift implements IDatabaseRepository {
from7To8: (m, v8) async { from7To8: (m, v8) async {
await m.create(v8.storeEntity); await m.create(v8.storeEntity);
}, },
from8To9: (m, v9) async {
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
},
), ),
); );
@@ -9,17 +9,17 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i3; as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4; as i4;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i6; as i5;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i7; as i6;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i8; as i7;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i9; as i8;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i10; as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i11; as i11;
@@ -48,19 +48,19 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i3.$StackEntityTable stackEntity = i3.$StackEntityTable(this); late final i3.$StackEntityTable stackEntity = i3.$StackEntityTable(this);
late final i4.$LocalAssetEntityTable localAssetEntity = i4 late final i4.$LocalAssetEntityTable localAssetEntity = i4
.$LocalAssetEntityTable(this); .$LocalAssetEntityTable(this);
late final i5.$RemoteAlbumEntityTable remoteAlbumEntity = i5 late final i5.$LocalAlbumEntityTable localAlbumEntity = i5
.$RemoteAlbumEntityTable(this);
late final i6.$LocalAlbumEntityTable localAlbumEntity = i6
.$LocalAlbumEntityTable(this); .$LocalAlbumEntityTable(this);
late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i7 late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i6
.$LocalAlbumAssetEntityTable(this); .$LocalAlbumAssetEntityTable(this);
late final i8.$UserMetadataEntityTable userMetadataEntity = i8 late final i7.$UserMetadataEntityTable userMetadataEntity = i7
.$UserMetadataEntityTable(this); .$UserMetadataEntityTable(this);
late final i9.$PartnerEntityTable partnerEntity = i9.$PartnerEntityTable( late final i8.$PartnerEntityTable partnerEntity = i8.$PartnerEntityTable(
this, this,
); );
late final i10.$RemoteExifEntityTable remoteExifEntity = i10 late final i9.$RemoteExifEntityTable remoteExifEntity = i9
.$RemoteExifEntityTable(this); .$RemoteExifEntityTable(this);
late final i10.$RemoteAlbumEntityTable remoteAlbumEntity = i10
.$RemoteAlbumEntityTable(this);
late final i11.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = i11 late final i11.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = i11
.$RemoteAlbumAssetEntityTable(this); .$RemoteAlbumAssetEntityTable(this);
late final i12.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i12 late final i12.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i12
@@ -84,7 +84,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteAssetEntity, remoteAssetEntity,
stackEntity, stackEntity,
localAssetEntity, localAssetEntity,
remoteAlbumEntity,
localAlbumEntity, localAlbumEntity,
localAlbumAssetEntity, localAlbumAssetEntity,
i4.idxLocalAssetChecksum, i4.idxLocalAssetChecksum,
@@ -95,6 +94,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
userMetadataEntity, userMetadataEntity,
partnerEntity, partnerEntity,
remoteExifEntity, remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity, remoteAlbumAssetEntity,
remoteAlbumUserEntity, remoteAlbumUserEntity,
memoryEntity, memoryEntity,
@@ -102,7 +102,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
personEntity, personEntity,
assetFaceEntity, assetFaceEntity,
storeEntity, storeEntity,
i10.idxLatLng, i9.idxLatLng,
]; ];
@override @override
i0.StreamQueryUpdateRules i0.StreamQueryUpdateRules
@@ -123,33 +123,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
), ),
result: [i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete)], result: [i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete)],
), ),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.update),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_album_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('local_album_entity', kind: i0.UpdateKind.update),
],
),
i0.WritePropagation( i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName( on: i0.TableUpdateQuery.onTableName(
'local_asset_entity', 'local_asset_entity',
@@ -200,6 +173,24 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('remote_exif_entity', kind: i0.UpdateKind.delete), i0.TableUpdate('remote_exif_entity', kind: i0.UpdateKind.delete),
], ],
), ),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.update),
],
),
i0.WritePropagation( i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName( on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity', 'remote_asset_entity',
@@ -299,18 +290,18 @@ class $DriftManager {
i3.$$StackEntityTableTableManager(_db, _db.stackEntity); i3.$$StackEntityTableTableManager(_db, _db.stackEntity);
i4.$$LocalAssetEntityTableTableManager get localAssetEntity => i4.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i4.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity); i4.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i5.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity => i5.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i5.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity); i5.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity => i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity); .$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i8.$$UserMetadataEntityTableTableManager get userMetadataEntity => i7.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i8.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); i7.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i9.$$PartnerEntityTableTableManager get partnerEntity => i8.$$PartnerEntityTableTableManager get partnerEntity =>
i9.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); i8.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i10.$$RemoteExifEntityTableTableManager get remoteExifEntity => i9.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i10.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity); i9.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i10.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i10.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i11.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity => i11.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i11.$$RemoteAlbumAssetEntityTableTableManager( i11.$$RemoteAlbumAssetEntityTableTableManager(
_db, _db,
@@ -3435,391 +3435,6 @@ i1.GeneratedColumn<int> _column_89(String aliasedName) =>
true, true,
type: i1.DriftSqlType.int, type: i1.DriftSqlType.int,
); );
final class Schema9 extends i0.VersionedSchema {
Schema9({required super.database}) : super(version: 9);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
idxLatLng,
];
late final Shape16 userEntity = Shape16(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_84,
_column_85,
_column_5,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 localAssetEntity = Shape2(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 localAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
}
class Shape19 extends i0.VersionedTable {
Shape19({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get backupSelection =>
columnsByName['backup_selection']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<bool> get isIosSharedAlbum =>
columnsByName['is_ios_shared_album']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get linkedRemoteAlbumId =>
columnsByName['linked_remote_album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get marker_ =>
columnsByName['marker']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<String> _column_90(String aliasedName) =>
i1.GeneratedColumn<String>(
'linked_remote_album_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_album_entity (id) ON DELETE SET NULL',
),
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -3828,7 +3443,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7, required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -3867,11 +3481,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from7To8(migrator, schema); await from7To8(migrator, schema);
return 8; return 8;
case 8:
final schema = Schema9(database: database);
final migrator = i1.Migrator(database, schema);
await from8To9(migrator, schema);
return 9;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -3886,7 +3495,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7, required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -3896,6 +3504,5 @@ i1.OnUpgrade stepByStep({
from5To6: from5To6, from5To6: from5To6,
from6To7: from6To7, from6To7: from6To7,
from7To8: from7To8, from7To8: from7To8,
from8To9: from8To9,
), ),
); );
@@ -1,12 +1,11 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/database.utils.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset } enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
@@ -50,13 +49,6 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.localAlbumEntity).toDto(assetCount: row.read(assetCount) ?? 0)).get(); return query.map((row) => row.readTable(_db.localAlbumEntity).toDto(assetCount: row.read(assetCount) ?? 0)).get();
} }
Future<List<LocalAlbum>> getBackupAlbums() async {
final query = _db.localAlbumEntity.select()
..where((row) => row.backupSelection.equalsValue(BackupSelection.selected));
return query.map((row) => row.toDto()).get();
}
Future<void> delete(String albumId) => transaction(() async { Future<void> delete(String albumId) => transaction(() async {
// Remove all assets that are only in this particular album // Remove all assets that are only in this particular album
// We cannot remove all assets in the album because they might be in other albums in iOS // We cannot remove all assets in the album because they might be in other albums in iOS
@@ -343,16 +335,4 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
Future<int> getCount() { Future<int> getCount() {
return _db.managers.localAlbumEntity.count(); return _db.managers.localAlbumEntity.count();
} }
Future unlinkRemoteAlbum(String id) async {
return _db.localAlbumEntity.update()
..where((row) => row.id.equals(id))
..write(const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null)));
}
Future linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
return _db.localAlbumEntity.update()
..where((row) => row.id.equals(localAlbumId))
..write(LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(remoteAlbumId)));
}
} }
@@ -0,0 +1,65 @@
import 'dart:io';
import 'package:cronet_http/cronet_http.dart';
import 'package:cupertino_http/cupertino_http.dart';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:path_provider/path_provider.dart';
class NetworkRepository {
static late Directory _cachePath;
static late String _userAgent;
static final _clients = <String, http.Client>{};
static Future<void> init() {
return (
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
getUserAgentString().then((userAgent) => _userAgent = userAgent),
).wait;
}
static void reset() {
Future.microtask(init);
for (final client in _clients.values) {
client.close();
}
_clients.clear();
}
const NetworkRepository();
http.Client getHttpClient(
String directoryName, {
int diskCapacity = 100 << 20,
int memoryCapacity = 10 << 20,
int maxConnections = 6,
CacheMode cacheMode = CacheMode.disk,
}) {
final cachedClient = _clients[directoryName];
if (cachedClient != null) {
return cachedClient;
}
final directory = Directory('${_cachePath.path}/$directoryName');
directory.createSync(recursive: true);
if (Platform.isAndroid) {
final engine = CronetEngine.build(
cacheMode: cacheMode,
cacheMaxSize: diskCapacity,
storagePath: directory.path,
userAgent: _userAgent,
);
return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true);
}
final config = URLSessionConfiguration.defaultSessionConfiguration()
..httpMaximumConnectionsPerHost = maxConnections
..cache = URLCache.withCapacity(
diskCapacity: diskCapacity,
memoryCapacity: memoryCapacity,
directory: directory.uri,
)
..httpAdditionalHeaders = {'User-Agent': _userAgent};
return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config);
}
}
@@ -113,15 +113,6 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.getSingleOrNull(); .getSingleOrNull();
} }
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
final query = _db.remoteAlbumEntity.select()
..where((row) => row.name.equals(albumName) & row.ownerId.equals(ownerId))
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(1);
return query.map((row) => row.toDto(ownerName: '', isShared: false)).getSingleOrNull();
}
Future<void> create(RemoteAlbum album, List<String> assetIds) async { Future<void> create(RemoteAlbum album, List<String> assetIds) async {
await _db.transaction(() async { await _db.transaction(() async {
final entity = RemoteAlbumEntityCompanion( final entity = RemoteAlbumEntityCompanion(
@@ -330,42 +321,6 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
Future<int> getCount() { Future<int> getCount() {
return _db.managers.remoteAlbumEntity.count(); return _db.managers.remoteAlbumEntity.count();
} }
Future<List<String>> getLinkedAssetIds(String userId, String localAlbumId, String remoteAlbumId) async {
// Find remote asset ids that:
// 1. Belong to the provided local album (via local_album_asset_entity)
// 2. Have been uploaded (i.e. a matching remote asset exists for the same checksum & owner)
// 3. Are NOT already in the remote album (remote_album_asset_entity)
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id])
..join([
innerJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
innerJoin(
_db.localAlbumAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
// Left join remote album assets to exclude those already in the remote album
leftOuterJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id) &
_db.remoteAlbumAssetEntity.albumId.equals(remoteAlbumId),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.localAlbumAssetEntity.albumId.equals(localAlbumId) &
_db.remoteAlbumAssetEntity.assetId.isNull(), // only those not yet linked
);
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get();
}
} }
extension on RemoteAlbumEntityData { extension on RemoteAlbumEntityData {
@@ -16,13 +16,6 @@ class StorageRepository {
file = await entity?.originFile; file = await entity?.originFile;
if (file == null) { if (file == null) {
log.warning("Cannot get file for asset $assetId"); log.warning("Cannot get file for asset $assetId");
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("File for asset $assetId does not exist");
return null;
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
log.warning("Error getting file for asset $assetId", error, stackTrace); log.warning("Error getting file for asset $assetId", error, stackTrace);
@@ -41,13 +34,6 @@ class StorageRepository {
log.warning( log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", "Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
); );
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("Motion file for asset ${asset.id} does not exist");
return null;
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
log.warning( log.warning(
@@ -4,11 +4,13 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class SyncApiRepository { class SyncApiRepository {
static final _client = const NetworkRepository().getHttpClient('api');
final Logger _logger = Logger('SyncApiRepository'); final Logger _logger = Logger('SyncApiRepository');
final ApiService _api; final ApiService _api;
SyncApiRepository(this._api); SyncApiRepository(this._api);
@@ -20,10 +22,8 @@ class SyncApiRepository {
Future<void> streamChanges( Future<void> streamChanges(
Function(List<SyncEvent>, Function() abort) onData, { Function(List<SyncEvent>, Function() abort) onData, {
int batchSize = kSyncEventBatchSize, int batchSize = kSyncEventBatchSize,
http.Client? httpClient,
}) async { }) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final client = httpClient ?? http.Client();
final endpoint = "${_api.apiClient.basePath}/sync/stream"; final endpoint = "${_api.apiClient.basePath}/sync/stream";
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
@@ -70,7 +70,7 @@ class SyncApiRepository {
} }
try { try {
final response = await client.send(request); final response = await _client.send(request);
if (response.statusCode != 200) { if (response.statusCode != 200) {
final errorBody = await response.stream.bytesToString(); final errorBody = await response.stream.bytesToString();
@@ -101,8 +101,6 @@ class SyncApiRepository {
} catch (error, stack) { } catch (error, stack) {
_logger.severe("Error processing stream", error, stack); _logger.severe("Error processing stream", error, stack);
return Future.error(error, stack); return Future.error(error, stack);
} finally {
client.close();
} }
stopwatch.stop(); stopwatch.stop();
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms"); _logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
+19 -3
View File
@@ -15,7 +15,9 @@ import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
@@ -25,6 +27,7 @@ import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/deep_link.service.dart'; import 'package:immich_mobile/services/deep_link.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart';
@@ -112,6 +115,8 @@ Future<void> initApp() async {
yield LicenseEntryWithLineBreaks([license.key], license.value); yield LicenseEntryWithLineBreaks([license.key], license.value);
} }
}); });
await NetworkRepository.init();
} }
class ImmichApp extends ConsumerStatefulWidget { class ImmichApp extends ConsumerStatefulWidget {
@@ -204,11 +209,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// needs to be delayed so that EasyLocalization is working // needs to be delayed so that EasyLocalization is working
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
ref.read(backgroundServiceProvider).disableService(); ref.read(driftBackgroundUploadFgService).enableSyncService();
ref.read(driftBackgroundUploadFgService).enable(); if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
ref.read(backgroundServiceProvider).disableService();
ref.read(driftBackgroundUploadFgService).enableUploadService();
}
} else { } else {
ref.read(driftBackgroundUploadFgService).disable();
ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.read(driftBackgroundUploadFgService).disableUploadService();
} }
}); });
@@ -221,6 +229,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
super.dispose(); super.dispose();
} }
@override
void reassemble() {
if (kDebugMode) {
NetworkRepository.reset();
}
super.reassemble();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final router = ref.watch(appRouterProvider); final router = ref.watch(appRouterProvider);
@@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -42,10 +43,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
await ref.read(backgroundSyncProvider).syncRemote(); await ref.read(backgroundSyncProvider).syncRemote();
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(driftBackgroundUploadFgService).enableUploadService();
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id); await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
} }
Future<void> stopBackup() async { Future<void> stopBackup() async {
await ref.read(driftBackgroundUploadFgService).disableUploadService();
await ref.read(driftBackupProvider.notifier).cancel(); await ref.read(driftBackupProvider.notifier).cancel();
} }
@@ -7,7 +7,6 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -27,10 +26,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
String _searchQuery = ''; String _searchQuery = '';
bool _isSearchMode = false; bool _isSearchMode = false;
int _initialTotalAssetCount = 0; int _initialTotalAssetCount = 0;
bool _hasPopped = false;
late ValueNotifier<bool> _enableSyncUploadAlbum; late ValueNotifier<bool> _enableSyncUploadAlbum;
late TextEditingController _searchController; late TextEditingController _searchController;
late FocusNode _searchFocusNode; late FocusNode _searchFocusNode;
Future? _handleLinkedAlbumFuture;
@override @override
void initState() { void initState() {
@@ -45,36 +44,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount)); _initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
} }
Future<void> _handlePagePopped() async {
final user = ref.read(currentUserProvider);
if (user == null) {
return;
}
final enableSyncUploadAlbum = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final selectedAlbums = ref
.read(backupAlbumProvider)
.where((a) => a.backupSelection == BackupSelection.selected)
.toList();
if (enableSyncUploadAlbum && selectedAlbums.isNotEmpty) {
setState(() {
_handleLinkedAlbumFuture = ref.read(syncLinkedAlbumServiceProvider).manageLinkedAlbums(selectedAlbums, user.id);
});
await _handleLinkedAlbumFuture;
}
// Restart backup if total count changed and backup is enabled
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (totalChanged && isBackupEnabled) {
await ref.read(driftBackupProvider.notifier).cancel();
await ref.read(driftBackupProvider.notifier).startBackup(user.id);
}
}
@override @override
void dispose() { void dispose() {
_enableSyncUploadAlbum.dispose(); _enableSyncUploadAlbum.dispose();
@@ -96,12 +65,42 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList(); final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList();
final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList();
// handleSyncAlbumToggle(bool isEnable) async {
// if (isEnable) {
// await ref.read(albumProvider.notifier).refreshRemoteAlbums();
// for (final album in selectedBackupAlbums) {
// await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
// }
// }
// }
return PopScope( return PopScope(
canPop: false, onPopInvokedWithResult: (didPop, result) async {
onPopInvokedWithResult: (didPop, _) async { // There is an issue with Flutter where the pop event
if (!didPop) { // can be triggered multiple times, so we guard it with _hasPopped
await _handlePagePopped(); if (didPop && !_hasPopped) {
Navigator.of(context).pop(); _hasPopped = true;
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
if (currentTotalAssetCount != _initialTotalAssetCount) {
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (!isBackupEnabled) {
return;
}
final backupNotifier = ref.read(driftBackupProvider.notifier);
backupNotifier.cancel().then((_) {
backupNotifier.startBackup(currentUser.id);
});
}
} }
}, },
child: Scaffold( child: Scaffold(
@@ -140,123 +139,103 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
], ],
elevation: 0, elevation: 0,
), ),
body: Stack( body: CustomScrollView(
children: [ physics: const ClampingScrollPhysics(),
CustomScrollView( slivers: [
physics: const ClampingScrollPhysics(), SliverToBoxAdapter(
slivers: [ child: Column(
SliverToBoxAdapter( crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Padding(
children: [ padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
Padding( child: Text(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), "backup_album_selection_page_selection_info",
child: Text( style: context.textTheme.titleSmall,
"backup_album_selection_page_selection_info", ).t(context: context),
style: context.textTheme.titleSmall, ),
).t(context: context),
),
// Selected Album Chips // Selected Album Chips
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap( child: Wrap(
children: [ children: [
_SelectedAlbumNameChips(selectedBackupAlbums: selectedBackupAlbums), _SelectedAlbumNameChips(selectedBackupAlbums: selectedBackupAlbums),
_ExcludedAlbumNameChips(excludedBackupAlbums: excludedBackupAlbums), _ExcludedAlbumNameChips(excludedBackupAlbums: excludedBackupAlbums),
], ],
), ),
), ),
ListTile(
title: Text( // SettingsSwitchListTile(
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}), // valueNotifier: _enableSyncUploadAlbum,
style: context.textTheme.titleSmall, // title: "sync_albums".t(context: context),
), // subtitle: "sync_upload_album_setting_subtitle".t(context: context),
subtitle: Padding( // contentPadding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 8.0), // titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
child: Text( // subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
"backup_album_selection_page_albums_tap", // onChanged: handleSyncAlbumToggle,
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor), // ),
).t(context: context), ListTile(
), title: Text(
trailing: IconButton( "albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),
splashRadius: 16, style: context.textTheme.titleSmall,
icon: Icon(Icons.info, size: 20, color: context.primaryColor), ),
onPressed: () { subtitle: Padding(
showDialog( padding: const EdgeInsets.symmetric(vertical: 8.0),
context: context, child: Text(
builder: (BuildContext context) { "backup_album_selection_page_albums_tap",
return AlertDialog( style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
shape: const RoundedRectangleBorder( ).t(context: context),
borderRadius: BorderRadius.all(Radius.circular(10)), ),
), trailing: IconButton(
elevation: 5, splashRadius: 16,
title: Text( icon: Icon(Icons.info, size: 20, color: context.primaryColor),
'backup_album_selection_page_selection_info', onPressed: () {
style: TextStyle( // show the dialog
fontSize: 16, showDialog(
fontWeight: FontWeight.bold, context: context,
color: context.primaryColor, builder: (BuildContext context) {
), return AlertDialog(
).t(context: context), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
content: SingleChildScrollView( elevation: 5,
child: ListBody( title: Text(
children: [ 'backup_album_selection_page_selection_info',
const Text( style: TextStyle(
'backup_album_selection_page_assets_scatter', fontSize: 16,
style: TextStyle(fontSize: 14), fontWeight: FontWeight.bold,
).t(context: context), color: context.primaryColor,
], ),
), ).t(context: context),
), content: SingleChildScrollView(
); child: ListBody(
}, children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(fontSize: 14),
).t(context: context),
],
),
),
); );
}, },
), );
), },
if (Platform.isAndroid)
_SelectAllButton(filteredAlbums: filteredAlbums, selectedBackupAlbums: selectedBackupAlbums),
],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
} else {
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
}
},
),
],
),
if (_handleLinkedAlbumFuture != null)
FutureBuilder(
future: _handleLinkedAlbumFuture,
builder: (context, snapshot) {
return SizedBox(
height: double.infinity,
width: double.infinity,
child: Container(
color: context.scaffoldBackgroundColor.withValues(alpha: 0.8),
child: Center(
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
const CircularProgressIndicator(strokeWidth: 4),
Text("Creating linked albums...", style: context.textTheme.labelLarge),
],
),
),
), ),
); ),
},
if (Platform.isAndroid)
_SelectAllButton(filteredAlbums: filteredAlbums, selectedBackupAlbums: selectedBackupAlbums),
],
), ),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
} else {
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
}
},
),
], ],
), ),
), ),
@@ -79,7 +79,7 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
ref.read(readonlyModeProvider.notifier).setReadonlyMode(false); ref.read(readonlyModeProvider.notifier).setReadonlyMode(false);
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider)); await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
await ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); await ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
await ref.read(driftBackgroundUploadFgService).disable(); await ref.read(driftBackgroundUploadFgService).disableUploadService();
} }
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
@@ -2,10 +2,8 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -23,23 +21,14 @@ class SplashScreenPage extends StatefulHookConsumerWidget {
class SplashScreenPageState extends ConsumerState<SplashScreenPage> { class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final log = Logger("SplashScreenPage"); final log = Logger("SplashScreenPage");
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final lockManager = ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)); ref
.read(authProvider.notifier)
lockManager.requestHolderToClose(); .setOpenApiServiceEndpoint()
lockManager .then(logConnectionInfo)
.acquireLock() .whenComplete(() => resumeSession());
.timeout(const Duration(seconds: 5))
.whenComplete(
() => ref
.read(authProvider.notifier)
.setOpenApiServiceEndpoint()
.then(logConnectionInfo)
.whenComplete(() => resumeSession()),
);
} }
void logConnectionInfo(String? endpoint) { void logConnectionInfo(String? endpoint) {
+58 -27
View File
@@ -59,9 +59,9 @@ class BackgroundWorkerFgHostApi {
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<void> enable() async { Future<void> enableSyncWorker() async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -82,9 +82,32 @@ class BackgroundWorkerFgHostApi {
} }
} }
Future<void> disable() async { Future<void> enableUploadWorker(int callbackHandle) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[callbackHandle]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
Future<void> disableUploadWorker() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -141,34 +164,13 @@ class BackgroundWorkerBgHostApi {
return; return;
} }
} }
Future<void> close() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
} }
abstract class BackgroundWorkerFlutterApi { abstract class BackgroundWorkerFlutterApi {
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec(); static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
Future<void> onLocalSync(int? maxSeconds);
Future<void> onIosUpload(bool isRefresh, int? maxSeconds); Future<void> onIosUpload(bool isRefresh, int? maxSeconds);
Future<void> onAndroidUpload(); Future<void> onAndroidUpload();
@@ -181,6 +183,35 @@ abstract class BackgroundWorkerFlutterApi {
String messageChannelSuffix = '', String messageChannelSuffix = '',
}) { }) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
{
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
assert(
message != null,
'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync was null.',
);
final List<Object?> args = (message as List<Object?>?)!;
final int? arg_maxSeconds = (args[0] as int?);
try {
await api.onLocalSync(arg_maxSeconds);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
{ {
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix', 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix',
@@ -1,6 +1,5 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -39,14 +38,14 @@ class DriftPlacePage extends StatelessWidget {
} }
} }
class _PlaceSliverAppBar extends HookWidget { class _PlaceSliverAppBar extends StatelessWidget {
const _PlaceSliverAppBar({required this.search}); const _PlaceSliverAppBar({required this.search});
final ValueNotifier<String?> search; final ValueNotifier<String?> search;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final searchFocusNode = useFocusNode(); final searchFocusNode = FocusNode();
return SliverAppBar( return SliverAppBar(
floating: true, floating: true,
@@ -19,7 +19,6 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/common/search_field.dart';
@@ -40,12 +39,8 @@ class AlbumSelector extends ConsumerStatefulWidget {
class _AlbumSelectorState extends ConsumerState<AlbumSelector> { class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
bool isGrid = false; bool isGrid = false;
final searchController = TextEditingController(); final searchController = TextEditingController();
QuickFilterMode filterMode = QuickFilterMode.all;
final searchFocusNode = FocusNode(); final searchFocusNode = FocusNode();
List<RemoteAlbum> sortedAlbums = [];
List<RemoteAlbum> shownAlbums = [];
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true);
@override @override
void initState() { void initState() {
@@ -57,7 +52,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
}); });
searchController.addListener(() { searchController.addListener(() {
onSearch(searchController.text, filter.mode); onSearch(searchController.text, filterMode);
}); });
searchFocusNode.addListener(() { searchFocusNode.addListener(() {
@@ -67,11 +62,9 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
}); });
} }
void onSearch(String searchTerm, QuickFilterMode filterMode) { void onSearch(String searchTerm, QuickFilterMode sortMode) {
final userId = ref.watch(currentUserProvider)?.id; final userId = ref.watch(currentUserProvider)?.id;
filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode); ref.read(remoteAlbumProvider.notifier).searchAlbums(searchTerm, userId, sortMode);
filterAlbums();
} }
Future<void> onRefresh() async { Future<void> onRefresh() async {
@@ -84,60 +77,17 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
}); });
} }
void changeFilter(QuickFilterMode mode) { void changeFilter(QuickFilterMode sortMode) {
setState(() { setState(() {
filter = filter.copyWith(mode: mode); filterMode = sortMode;
}); });
filterAlbums();
}
Future<void> changeSort(AlbumSort sort) async {
setState(() {
this.sort = sort;
});
await sortAlbums();
} }
void clearSearch() { void clearSearch() {
setState(() { setState(() {
filter = filter.copyWith(mode: QuickFilterMode.all, query: null); filterMode = QuickFilterMode.all;
searchController.clear(); searchController.clear();
}); ref.read(remoteAlbumProvider.notifier).clearSearch();
filterAlbums();
}
Future<void> sortAlbums() async {
final sorted = await ref
.read(remoteAlbumProvider.notifier)
.sortAlbums(ref.read(remoteAlbumProvider).albums, sort.mode, isReverse: sort.isReverse);
setState(() {
sortedAlbums = sorted;
});
// we need to re-filter the albums after sorting
// so shownAlbums gets updated
filterAlbums();
}
Future<void> filterAlbums() async {
if (filter.query == null) {
setState(() {
shownAlbums = sortedAlbums;
});
return;
}
final filteredAlbums = ref
.read(remoteAlbumProvider.notifier)
.searchAlbums(sortedAlbums, filter.query!, filter.userId, filter.mode);
setState(() {
shownAlbums = filteredAlbums;
}); });
} }
@@ -150,12 +100,9 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final userId = ref.watch(currentUserProvider)?.id; final albums = ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums));
// refilter and sort when albums change final userId = ref.watch(currentUserProvider)?.id;
ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async {
await sortAlbums();
});
return MultiSliver( return MultiSliver(
children: [ children: [
@@ -163,28 +110,26 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
searchController: searchController, searchController: searchController,
searchFocusNode: searchFocusNode, searchFocusNode: searchFocusNode,
onSearch: onSearch, onSearch: onSearch,
filterMode: filter.mode, filterMode: filterMode,
onClearSearch: clearSearch, onClearSearch: clearSearch,
), ),
_QuickFilterButtonRow( _QuickFilterButtonRow(
filterMode: filter.mode, filterMode: filterMode,
onChangeFilter: changeFilter, onChangeFilter: changeFilter,
onSearch: onSearch, onSearch: onSearch,
searchController: searchController, searchController: searchController,
), ),
_QuickSortAndViewMode(isGrid: isGrid, onToggleViewMode: toggleViewMode, onSortChanged: changeSort), _QuickSortAndViewMode(isGrid: isGrid, onToggleViewMode: toggleViewMode),
isGrid isGrid
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected) ? _AlbumGrid(albums: albums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
: _AlbumList(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected), : _AlbumList(albums: albums, userId: userId, onAlbumSelected: widget.onAlbumSelected),
], ],
); );
} }
} }
class _SortButton extends ConsumerStatefulWidget { class _SortButton extends ConsumerStatefulWidget {
const _SortButton(this.onSortChanged); const _SortButton();
final Future<void> Function(AlbumSort) onSortChanged;
@override @override
ConsumerState<_SortButton> createState() => _SortButtonState(); ConsumerState<_SortButton> createState() => _SortButtonState();
@@ -203,15 +148,15 @@ class _SortButtonState extends ConsumerState<_SortButton> {
albumSortIsReverse = !albumSortIsReverse; albumSortIsReverse = !albumSortIsReverse;
isSorting = true; isSorting = true;
}); });
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
} else { } else {
setState(() { setState(() {
albumSortOption = sortMode; albumSortOption = sortMode;
isSorting = true; isSorting = true;
}); });
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
} }
await widget.onSortChanged.call(AlbumSort(mode: albumSortOption, isReverse: albumSortIsReverse));
setState(() { setState(() {
isSorting = false; isSorting = false;
}); });
@@ -449,11 +394,10 @@ class _QuickFilterButton extends StatelessWidget {
} }
class _QuickSortAndViewMode extends StatelessWidget { class _QuickSortAndViewMode extends StatelessWidget {
const _QuickSortAndViewMode({required this.isGrid, required this.onToggleViewMode, required this.onSortChanged}); const _QuickSortAndViewMode({required this.isGrid, required this.onToggleViewMode});
final bool isGrid; final bool isGrid;
final VoidCallback onToggleViewMode; final VoidCallback onToggleViewMode;
final Future<void> Function(AlbumSort) onSortChanged;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -463,7 +407,7 @@ class _QuickSortAndViewMode extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_SortButton(onSortChanged), const _SortButton(),
IconButton( IconButton(
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24), icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: onToggleViewMode, onPressed: onToggleViewMode,
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
@@ -130,7 +129,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
reloadSubscription?.cancel(); reloadSubscription?.cancel();
_prevPreCacheStream?.removeListener(_dummyListener); _prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener); _nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose(); super.dispose();
} }
@@ -598,7 +596,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Rebuild the widget when the asset viewer state changes // Rebuild the widget when the asset viewer state changes
// Using multiple selectors to avoid unnecessary rebuilds for other state changes // Using multiple selectors to avoid unnecessary rebuilds for other state changes
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
ref.watch(assetViewerProvider.select((s) => s.showingControls));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(assetViewerProvider.select((s) => s.stackIndex)); ref.watch(assetViewerProvider.select((s) => s.stackIndex));
ref.watch(isPlayingMotionVideoProvider); ref.watch(isPlayingMotionVideoProvider);
@@ -615,15 +612,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}); });
}); });
// Listen for control visibility changes and change system UI mode accordingly
ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async {
if (showingControls) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
});
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
// Issue: https://github.com/flutter/flutter/issues/109037 // Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable // TODO: Add a custom scrum builder once the fix lands on stable
@@ -62,7 +62,7 @@ class ViewerBottomBar extends ConsumerWidget {
duration: Durations.short2, duration: Durations.short2,
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: Durations.short4, duration: Durations.short4,
child: isSheetOpen child: isSheetOpen || isReadonlyModeEnabled
? const SizedBox.shrink() ? const SizedBox.shrink()
: Theme( : Theme(
data: context.themeData.copyWith( data: context.themeData.copyWith(
@@ -72,14 +72,14 @@ class ViewerBottomBar extends ConsumerWidget {
), ),
), ),
child: Container( child: Container(
height: context.padding.bottom + (asset.isVideo ? 160 : 90),
color: Colors.black.withAlpha(125), color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), padding: EdgeInsets.only(bottom: context.padding.bottom),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (asset.isVideo) const VideoControls(), if (asset.isVideo) const VideoControls(),
if (!isInLockedView && !isReadonlyModeEnabled) if (!isInLockedView) Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
], ],
), ),
), ),
@@ -1,9 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -18,74 +16,22 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget { class RemoteAlbumBottomSheet extends ConsumerWidget {
final RemoteAlbum album; final RemoteAlbum album;
const RemoteAlbumBottomSheet({super.key, required this.album}); const RemoteAlbumBottomSheet({super.key, required this.album});
@override @override
ConsumerState<RemoteAlbumBottomSheet> createState() => _RemoteAlbumBottomSheetState(); Widget build(BuildContext context, WidgetRef ref) {
}
class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet> {
late DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider); final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
if (selectedAssets.isEmpty) {
return;
}
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
if (addedCount != selectedAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name}),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}),
);
}
ref.read(multiSelectProvider.notifier).reset();
}
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet( return BaseBottomSheet(
controller: sheetController, initialChildSize: 0.25,
initialChildSize: 0.45, maxChildSize: 0.4,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
actions: [ actions: [
const ShareActionButton(source: ActionSource.timeline), const ShareActionButton(source: ActionSource.timeline),
@@ -106,11 +52,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(source: ActionSource.timeline), const UploadActionButton(source: ActionSource.timeline),
], ],
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id), RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: album.id),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
], ],
); );
} }
@@ -62,6 +62,11 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
return; return;
} }
yield image; yield image;
} catch (e) {
evict();
if (!isCancelled) {
_log.severe('Error loading image', e);
}
} finally { } finally {
this.request = null; this.request = null;
} }
@@ -7,13 +7,11 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider> class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> { with CancellableImageProviderMixin<RemoteThumbProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId; final String assetId;
RemoteThumbProvider({required this.assetId}); RemoteThumbProvider({required this.assetId});
@@ -39,7 +37,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
final request = this.request = RemoteImageRequest( final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId), uri: getThumbnailUrlForRemoteId(key.assetId),
headers: ApiService.getRequestHeaders(), headers: ApiService.getRequestHeaders(),
cacheManager: cacheManager,
); );
return loadRequest(request, decode); return loadRequest(request, decode);
} }
@@ -60,7 +57,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider> class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> { with CancellableImageProviderMixin<RemoteFullImageProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId; final String assetId;
RemoteFullImageProvider({required this.assetId}); RemoteFullImageProvider({required this.assetId});
@@ -92,11 +88,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
} }
final headers = ApiService.getRequestHeaders(); final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest( final request = this.request = RemoteImageRequest(uri: getPreviewUrlForRemoteId(key.assetId), headers: headers);
uri: getPreviewUrlForRemoteId(key.assetId),
headers: headers,
cacheManager: cacheManager,
);
yield* loadRequest(request, decode); yield* loadRequest(request, decode);
if (isCancelled) { if (isCancelled) {
@@ -47,12 +47,10 @@ class _DriftMapState extends ConsumerState<DriftMap> {
MapLibreMapController? mapController; MapLibreMapController? mapController;
final _reloadMutex = AsyncMutex(); final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2)); final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
@override @override
void dispose() { void dispose() {
_debouncer.dispose(); _debouncer.dispose();
bottomSheetOffset.dispose();
super.dispose(); super.dispose();
} }
@@ -159,8 +157,8 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack( return Stack(
children: [ children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady), _Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset), _MyLocationButton(onZoomToLocation: onZoomToLocation),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset), const MapBottomSheet(),
], ],
); );
} }
@@ -193,53 +191,21 @@ class _Map extends StatelessWidget {
} }
} }
class _DynamicBottomSheet extends StatefulWidget { class _MyLocationButton extends StatelessWidget {
final ValueNotifier<double> bottomSheetOffset; const _MyLocationButton({required this.onZoomToLocation});
const _DynamicBottomSheet({required this.bottomSheetOffset});
@override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
}
class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
@override
Widget build(BuildContext context) {
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
widget.bottomSheetOffset.value = notification.extent;
return true;
},
child: const MapBottomSheet(),
);
}
}
class _DynamicMyLocationButton extends StatelessWidget {
const _DynamicMyLocationButton({required this.onZoomToLocation, required this.bottomSheetOffset});
final VoidCallback onZoomToLocation; final VoidCallback onZoomToLocation;
final ValueNotifier<double> bottomSheetOffset;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<double>( return Positioned(
valueListenable: bottomSheetOffset, right: 0,
builder: (context, offset, child) { bottom: context.padding.bottom + 16,
return Positioned( child: ElevatedButton(
right: 16, onPressed: onZoomToLocation,
bottom: context.height * (offset - 0.02) + context.padding.bottom, style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: AnimatedOpacity( child: const Icon(Icons.my_location),
opacity: offset < 0.8 ? 1 : 0, ),
duration: const Duration(milliseconds: 150),
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.my_location),
),
),
);
},
); );
} }
} }
@@ -2,9 +2,11 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0; const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256); const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const double kTimelineSpacing = 2.0; const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300); const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB
@@ -10,7 +10,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:intl/intl.dart' hide TextDirection; import 'package:intl/intl.dart' hide TextDirection;
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView. /// for quick navigation of the BoxScrollView.
@@ -75,7 +74,6 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
} }
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin { class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
String? _lastLabel;
double _thumbTopOffset = 0.0; double _thumbTopOffset = 0.0;
bool _isDragging = false; bool _isDragging = false;
List<_Segment> _segments = []; List<_Segment> _segments = [];
@@ -174,7 +172,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_isDragging = true; _isDragging = true;
_labelAnimationController.forward(); _labelAnimationController.forward();
_fadeOutTimer?.cancel(); _fadeOutTimer?.cancel();
_lastLabel = null;
}); });
} }
@@ -192,11 +189,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
if (nearestMonthSegment != null) { if (nearestMonthSegment != null) {
_snapToSegment(nearestMonthSegment); _snapToSegment(nearestMonthSegment);
final label = nearestMonthSegment.scrollLabel;
if (_lastLabel != label) {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
_lastLabel = label;
}
} }
} }
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
@@ -82,12 +81,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} }
} else { } else {
_ref.read(backupProvider.notifier).cancelBackup(); _ref.read(backupProvider.notifier).cancelBackup();
final lockManager = _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort));
lockManager.requestHolderToClose();
debugPrint("Requested lock holder to close on resume");
await lockManager.acquireLock();
debugPrint("Lock acquired for background sync on resume");
final backgroundManager = _ref.read(backgroundSyncProvider); final backgroundManager = _ref.read(backgroundSyncProvider);
// Ensure proper cleanup before starting new background tasks // Ensure proper cleanup before starting new background tasks
@@ -105,8 +98,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
]).then((_) async { ]).then((_) async {
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
if (isEnableBackup) { if (isEnableBackup) {
final currentUser = _ref.read(currentUserProvider); final currentUser = _ref.read(currentUserProvider);
if (currentUser == null) { if (currentUser == null) {
@@ -115,10 +106,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
} }
if (isAlbumLinkedSyncEnable) {
await backgroundManager.syncLinkedAlbum();
}
}); });
} catch (e, stackTrace) { } catch (e, stackTrace) {
Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace); Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace);
@@ -143,7 +130,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
// do not stop/clean up anything on inactivity: issued on every orientation change // do not stop/clean up anything on inactivity: issued on every orientation change
} }
Future<void> handleAppPause() async { void handleAppPause() {
state = AppLifeCycleEnum.paused; state = AppLifeCycleEnum.paused;
_wasPaused = true; _wasPaused = true;
@@ -153,12 +140,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) { if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) {
_ref.read(backupProvider.notifier).cancelBackup(); _ref.read(backupProvider.notifier).cancelBackup();
} }
} else {
final backgroundManager = _ref.read(backgroundSyncProvider);
await backgroundManager.cancel();
await backgroundManager.cancelLocal();
_ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock();
debugPrint("Lock released on app pause");
} }
_ref.read(websocketProvider.notifier).disconnect(); _ref.read(websocketProvider.notifier).disconnect();
@@ -192,7 +173,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} }
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
_ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock();
return; return;
} }
@@ -1,6 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) { final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
@@ -19,7 +18,3 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
ref.onDispose(manager.cancel); ref.onDispose(manager.cancel);
return manager; return manager;
}); });
final isolateLockManagerProvider = Provider.family<IsolateLockManager, String>((ref, name) {
return IsolateLockManager(portName: name);
});
@@ -1,148 +1,25 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
// ignore: implementation_imports
import 'package:flutter_cache_manager/src/cache_store.dart';
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
abstract class RemoteCacheManager extends CacheManager { class RemoteImageCacheManager extends CacheManager {
static final _log = Logger('RemoteCacheManager');
RemoteCacheManager.custom(super.config, CacheStore store)
// Unfortunately, CacheStore is not a public API
// ignore: invalid_use_of_visible_for_testing_member
: super.custom(cacheStore: store);
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
});
// Unlike `putFileStream`, this method handles request cancellation,
// does not make a (slow) DB call checking if the file is already cached,
// does not synchronously check if a file exists,
// and deletes the file on cancellation without making these checks again.
Future<void> putStreamedFileToStore(
CacheStore store,
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) async {
final path = '${const Uuid().v1()}.$fileExtension';
final file = await store.fileSystem.createFile(path);
final sink = file.openWrite();
try {
await source.listen(sink.add, cancelOnError: true).asFuture();
} catch (e) {
try {
await sink.close();
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
try {
await sink.flush();
await sink.close();
} catch (e) {
try {
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
final cacheObject = CacheObject(
url,
key: key,
relativePath: path,
validTill: DateTime.now().add(maxAge),
eTag: eTag,
);
try {
await store.putFile(cacheObject);
} catch (e) {
try {
await file.delete();
} catch (e) {
_log.severe('Failed to delete untracked cache file: $e');
}
}
}
}
class RemoteImageCacheManager extends RemoteCacheManager {
static const key = 'remoteImageCacheKey'; static const key = 'remoteImageCacheKey';
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._(); static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30)); static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
static final _store = CacheStore(_config);
factory RemoteImageCacheManager() { factory RemoteImageCacheManager() {
return _instance; return _instance;
} }
RemoteImageCacheManager._() : super.custom(_config, _store); RemoteImageCacheManager._() : super(_config);
@override
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) {
return putStreamedFileToStore(
_store,
url,
source,
key: key,
eTag: eTag,
maxAge: maxAge,
fileExtension: fileExtension,
);
}
} }
/// The cache manager for full size images [ImmichRemoteImageProvider] class RemoteThumbnailCacheManager extends CacheManager {
class RemoteThumbnailCacheManager extends RemoteCacheManager {
static const key = 'remoteThumbnailCacheKey'; static const key = 'remoteThumbnailCacheKey';
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._(); static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30)); static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
static final _store = CacheStore(_config);
factory RemoteThumbnailCacheManager() { factory RemoteThumbnailCacheManager() {
return _instance; return _instance;
} }
RemoteThumbnailCacheManager._() : super.custom(_config, _store); RemoteThumbnailCacheManager._() : super(_config);
@override
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) {
return putStreamedFileToStore(
_store,
url,
source,
key: key,
eTag: eTag,
maxAge: maxAge,
fileExtension: fileExtension,
);
}
} }
@@ -12,42 +12,43 @@ import 'album.provider.dart';
class RemoteAlbumState { class RemoteAlbumState {
final List<RemoteAlbum> albums; final List<RemoteAlbum> albums;
final List<RemoteAlbum> filteredAlbums;
const RemoteAlbumState({required this.albums}); const RemoteAlbumState({required this.albums, List<RemoteAlbum>? filteredAlbums})
: filteredAlbums = filteredAlbums ?? albums;
RemoteAlbumState copyWith({List<RemoteAlbum>? albums}) { RemoteAlbumState copyWith({List<RemoteAlbum>? albums, List<RemoteAlbum>? filteredAlbums}) {
return RemoteAlbumState(albums: albums ?? this.albums); return RemoteAlbumState(albums: albums ?? this.albums, filteredAlbums: filteredAlbums ?? this.filteredAlbums);
} }
@override @override
String toString() => 'RemoteAlbumState(albums: ${albums.length})'; String toString() => 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length})';
@override @override
bool operator ==(covariant RemoteAlbumState other) { bool operator ==(covariant RemoteAlbumState other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals; final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.albums, albums); return listEquals(other.albums, albums) && listEquals(other.filteredAlbums, filteredAlbums);
} }
@override @override
int get hashCode => albums.hashCode; int get hashCode => albums.hashCode ^ filteredAlbums.hashCode;
} }
class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> { class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
late RemoteAlbumService _remoteAlbumService; late RemoteAlbumService _remoteAlbumService;
final _logger = Logger('RemoteAlbumNotifier'); final _logger = Logger('RemoteAlbumNotifier');
@override @override
RemoteAlbumState build() { RemoteAlbumState build() {
_remoteAlbumService = ref.read(remoteAlbumServiceProvider); _remoteAlbumService = ref.read(remoteAlbumServiceProvider);
return const RemoteAlbumState(albums: []); return const RemoteAlbumState(albums: [], filteredAlbums: []);
} }
Future<List<RemoteAlbum>> _getAll() async { Future<List<RemoteAlbum>> _getAll() async {
try { try {
final albums = await _remoteAlbumService.getAll(); final albums = await _remoteAlbumService.getAll();
state = state.copyWith(albums: albums); state = state.copyWith(albums: albums, filteredAlbums: albums);
return albums; return albums;
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Failed to fetch albums', error, stack); _logger.severe('Failed to fetch albums', error, stack);
@@ -59,21 +60,19 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
await _getAll(); await _getAll();
} }
List<RemoteAlbum> searchAlbums( void searchAlbums(String query, String? userId, [QuickFilterMode filterMode = QuickFilterMode.all]) {
List<RemoteAlbum> albums, final filtered = _remoteAlbumService.searchAlbums(state.albums, query, userId, filterMode);
String query,
String? userId, [ state = state.copyWith(filteredAlbums: filtered);
QuickFilterMode filterMode = QuickFilterMode.all,
]) {
return _remoteAlbumService.searchAlbums(albums, query, userId, filterMode);
} }
Future<List<RemoteAlbum>> sortAlbums( void clearSearch() {
List<RemoteAlbum> albums, state = state.copyWith(filteredAlbums: state.albums);
RemoteAlbumSortMode sortMode, { }
bool isReverse = false,
}) async { Future<void> sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) async {
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse); final sortedAlbums = await _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
state = state.copyWith(filteredAlbums: sortedAlbums);
} }
Future<RemoteAlbum?> createAlbum({ Future<RemoteAlbum?> createAlbum({
@@ -84,7 +83,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
try { try {
final album = await _remoteAlbumService.createAlbum(title: title, description: description, assetIds: assetIds); final album = await _remoteAlbumService.createAlbum(title: title, description: description, assetIds: assetIds);
state = state.copyWith(albums: [...state.albums, album]); state = state.copyWith(albums: [...state.albums, album], filteredAlbums: [...state.filteredAlbums, album]);
return album; return album;
} catch (error, stack) { } catch (error, stack) {
@@ -115,7 +114,11 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return album.id == albumId ? updatedAlbum : album; return album.id == albumId ? updatedAlbum : album;
}).toList(); }).toList();
state = state.copyWith(albums: updatedAlbums); final updatedFilteredAlbums = state.filteredAlbums.map((album) {
return album.id == albumId ? updatedAlbum : album;
}).toList();
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
return updatedAlbum; return updatedAlbum;
} catch (error, stack) { } catch (error, stack) {
@@ -136,7 +139,9 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
await _remoteAlbumService.deleteAlbum(albumId); await _remoteAlbumService.deleteAlbum(albumId);
final updatedAlbums = state.albums.where((album) => album.id != albumId).toList(); final updatedAlbums = state.albums.where((album) => album.id != albumId).toList();
state = state.copyWith(albums: updatedAlbums); final updatedFilteredAlbums = state.filteredAlbums.where((album) => album.id != albumId).toList();
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
} }
Future<List<RemoteAsset>> getAssets(String albumId) { Future<List<RemoteAsset>> getAssets(String albumId) {
@@ -159,7 +164,9 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
await _remoteAlbumService.removeUser(albumId, userId: userId); await _remoteAlbumService.removeUser(albumId, userId: userId);
final updatedAlbums = state.albums.where((album) => album.id != albumId).toList(); final updatedAlbums = state.albums.where((album) => album.id != albumId).toList();
state = state.copyWith(albums: updatedAlbums); final updatedFilteredAlbums = state.filteredAlbums.where((album) => album.id != albumId).toList();
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
} }
Future<void> setActivityStatus(String albumId, bool enabled) { Future<void> setActivityStatus(String albumId, bool enabled) {
+2 -5
View File
@@ -12,6 +12,7 @@ import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
// import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
@@ -322,11 +323,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
} }
try { try {
unawaited( unawaited(_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()));
_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) {
return _ref.read(backgroundSyncProvider).syncLinkedAlbum();
}),
);
} catch (error) { } catch (error) {
_log.severe("Error processing batched AssetUploadReadyV1 events: $error"); _log.severe("Error processing batched AssetUploadReadyV1 events: $error");
} }
+4 -4
View File
@@ -4,15 +4,16 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/user_agent.dart'; import 'package:immich_mobile/utils/user_agent.dart';
class ApiService implements Authentication { class ApiService implements Authentication {
static final _client = const NetworkRepository().getHttpClient('api');
late ApiClient _apiClient; late ApiClient _apiClient;
late UsersApi usersApi; late UsersApi usersApi;
@@ -50,6 +51,7 @@ class ApiService implements Authentication {
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint, authentication: this); _apiClient = ApiClient(basePath: endpoint, authentication: this);
_apiClient.client = _client;
_setUserAgentHeader(); _setUserAgentHeader();
if (_accessToken != null) { if (_accessToken != null) {
setAccessToken(_accessToken!); setAccessToken(_accessToken!);
@@ -134,13 +136,11 @@ class ApiService implements Authentication {
} }
Future<String> _getWellKnownEndpoint(String baseUrl) async { Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();
try { try {
var headers = {"Accept": "application/json"}; var headers = {"Accept": "application/json"};
headers.addAll(getRequestHeaders()); headers.addAll(getRequestHeaders());
final res = await client final res = await _client
.get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers) .get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers)
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
+5 -8
View File
@@ -282,8 +282,6 @@ class UploadService {
return buildUploadTask( return buildUploadTask(
file, file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName, originalFileName: originalFileName,
deviceAssetId: asset.id, deviceAssetId: asset.id,
metadata: metadata, metadata: metadata,
@@ -311,8 +309,6 @@ class UploadService {
return buildUploadTask( return buildUploadTask(
file, file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: asset.name, originalFileName: asset.name,
deviceAssetId: asset.id, deviceAssetId: asset.id,
fields: fields, fields: fields,
@@ -338,8 +334,6 @@ class UploadService {
Future<UploadTask> buildUploadTask( Future<UploadTask> buildUploadTask(
File file, { File file, {
required String group, required String group,
required DateTime createdAt,
required DateTime modifiedAt,
Map<String, String>? fields, Map<String, String>? fields,
String? originalFileName, String? originalFileName,
String? deviceAssetId, String? deviceAssetId,
@@ -353,12 +347,15 @@ class UploadService {
final headers = ApiService.getRequestHeaders(); final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId); final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
final stats = await file.stat();
final fileCreatedAt = stats.changed;
final fileModifiedAt = stats.modified;
final fieldsMap = { final fieldsMap = {
'filename': originalFileName ?? filename, 'filename': originalFileName ?? filename,
'deviceAssetId': deviceAssetId ?? '', 'deviceAssetId': deviceAssetId ?? '',
'deviceId': deviceId, 'deviceId': deviceId,
'fileCreatedAt': createdAt.toUtc().toIso8601String(), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': modifiedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': isFavorite?.toString() ?? 'false', 'isFavorite': isFavorite?.toString() ?? 'false',
'duration': '0', 'duration': '0',
if (fields != null) ...fields, if (fields != null) ...fields,
-25
View File
@@ -1,25 +0,0 @@
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
class AlbumFilter {
String? userId;
String? query;
QuickFilterMode mode;
AlbumFilter({required this.mode, this.userId, this.query});
AlbumFilter copyWith({String? userId, String? query, QuickFilterMode? mode}) {
return AlbumFilter(userId: userId ?? this.userId, query: query ?? this.query, mode: mode ?? this.mode);
}
}
class AlbumSort {
RemoteAlbumSortMode mode;
bool isReverse;
AlbumSort({required this.mode, this.isReverse = false});
AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) {
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum(
id: id,
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
backupSelection: backupSelection,
);
}
}
extension LocalAssetEntityDataHelper on LocalAssetEntityData {
LocalAsset toDto() {
return LocalAsset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
);
}
}
-19
View File
@@ -1,19 +0,0 @@
const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01
DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch) {
if (secondsSinceEpoch == null) {
return null;
}
final milliSeconds = secondsSinceEpoch * 1000;
if (milliSeconds < _minMillisecondsSinceEpoch || milliSeconds > _maxMillisecondsSinceEpoch) {
return null;
}
try {
return DateTime.fromMillisecondsSinceEpoch(milliSeconds);
} catch (e) {
return null;
}
}
+1 -9
View File
@@ -23,10 +23,8 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -270,17 +268,11 @@ Future<List<void>> runNewSync(WidgetRef ref, {bool full = false}) {
ref.read(backupProvider.notifier).cancelBackup(); ref.read(backupProvider.notifier).cancelBackup();
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final isAlbumLinkedSyncEnable = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
return Future.wait([ return Future.wait([
backgroundManager.syncLocal(full: full).then((_) { backgroundManager.syncLocal(full: full).then((_) {
Logger("runNewSync").fine("Hashing assets after syncLocal"); Logger("runNewSync").fine("Hashing assets after syncLocal");
return backgroundManager.hashAssets(); return backgroundManager.hashAssets();
}), }),
backgroundManager.syncRemote().then((_) { backgroundManager.syncRemote(),
if (isAlbumLinkedSyncEnable) {
return backgroundManager.syncLinkedAlbum();
}
}),
]); ]);
} }
@@ -8,14 +8,12 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart'; import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart';
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/models/asset_selection_state.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/asset_grid/upload_dialog.dart'; import 'package:immich_mobile/widgets/asset_grid/upload_dialog.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/widgets/common/drag_sheet.dart'; import 'package:immich_mobile/widgets/common/drag_sheet.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/draggable_scroll_controller.dart'; import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
final controlBottomAppBarNotifier = ControlBottomAppBarNotifier(); final controlBottomAppBarNotifier = ControlBottomAppBarNotifier();
@@ -47,7 +45,6 @@ class ControlBottomAppBar extends HookConsumerWidget {
final bool unfavorite; final bool unfavorite;
final bool unarchive; final bool unarchive;
final AssetSelectionState selectionAssetState; final AssetSelectionState selectionAssetState;
final List<Asset> selectedAssets;
const ControlBottomAppBar({ const ControlBottomAppBar({
super.key, super.key,
@@ -67,7 +64,6 @@ class ControlBottomAppBar extends HookConsumerWidget {
this.onRemoveFromAlbum, this.onRemoveFromAlbum,
this.onToggleLocked, this.onToggleLocked,
this.selectionAssetState = const AssetSelectionState(), this.selectionAssetState = const AssetSelectionState(),
this.selectedAssets = const [],
this.enabled = true, this.enabled = true,
this.unarchive = false, this.unarchive = false,
this.unfavorite = false, this.unfavorite = false,
@@ -104,18 +100,6 @@ class ControlBottomAppBar extends HookConsumerWidget {
); );
} }
/// Show existing AddToAlbumBottomSheet
void showAddToAlbumBottomSheet() {
showModalBottomSheet(
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))),
context: context,
builder: (BuildContext _) {
return AddToAlbumBottomSheet(assets: selectedAssets);
},
);
}
void handleRemoteDelete(bool force, Function(bool) deleteCb, {String? alertMsg}) { void handleRemoteDelete(bool force, Function(bool) deleteCb, {String? alertMsg}) {
if (!force) { if (!force) {
deleteCb(force); deleteCb(force);
@@ -137,15 +121,6 @@ class ControlBottomAppBar extends HookConsumerWidget {
label: "share_link".tr(), label: "share_link".tr(),
onPressed: enabled ? () => onShare(false) : null, onPressed: enabled ? () => onShare(false) : null,
), ),
if (!isInLockedView && hasRemote && albums.isNotEmpty)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 100),
child: ControlBoxButton(
iconData: Icons.photo_album,
label: "add_to_album".tr(),
onPressed: enabled ? showAddToAlbumBottomSheet : null,
),
),
if (hasRemote && onArchive != null) if (hasRemote && onArchive != null)
ControlBoxButton( ControlBoxButton(
iconData: unarchive ? Icons.unarchive_outlined : Icons.archive_outlined, iconData: unarchive ? Icons.unarchive_outlined : Icons.archive_outlined,
@@ -440,7 +440,6 @@ class MultiselectGrid extends HookConsumerWidget {
onUpload: onUpload, onUpload: onUpload,
enabled: !processing.value, enabled: !processing.value,
selectionAssetState: selectionAssetState.value, selectionAssetState: selectionAssetState.value,
selectedAssets: selection.value.toList(),
onStack: stackEnabled ? onStack : null, onStack: stackEnabled ? onStack : null,
onEditTime: editEnabled ? onEditTime : null, onEditTime: editEnabled ? onEditTime : null,
onEditLocation: editEnabled ? onEditLocation : null, onEditLocation: editEnabled ? onEditLocation : null,
@@ -4,9 +4,12 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DriftAlbumInfoListTile extends HookConsumerWidget { class DriftAlbumInfoListTile extends HookConsumerWidget {
@@ -19,6 +22,8 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
final bool isSelected = album.backupSelection == BackupSelection.selected; final bool isSelected = album.backupSelection == BackupSelection.selected;
final bool isExcluded = album.backupSelection == BackupSelection.excluded; final bool isExcluded = album.backupSelection == BackupSelection.excluded;
final syncAlbum = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
buildTileColor() { buildTileColor() {
if (isSelected) { if (isSelected) {
return context.isDarkTheme ? context.primaryColor.withAlpha(100) : context.primaryColor.withAlpha(25); return context.isDarkTheme ? context.primaryColor.withAlpha(100) : context.primaryColor.withAlpha(25);
@@ -70,6 +75,9 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album); ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
} else { } else {
ref.read(backupAlbumProvider.notifier).selectAlbum(album); ref.read(backupAlbumProvider.notifier).selectAlbum(album);
if (syncAlbum) {
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
} }
}, },
leading: buildIcon(), leading: buildIcon(),
@@ -1,8 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart';
@@ -260,7 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
const AppBarProfileInfoBox(), const AppBarProfileInfoBox(),
buildStorageInformation(), buildStorageInformation(),
const AppBarServerInfo(), const AppBarServerInfo(),
if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(), if (isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(), buildAppLogButton(),
buildSettingButton(), buildSettingButton(),
buildSignOutButton(), buildSignOutButton(),
@@ -121,6 +121,7 @@ class PhotoViewCore extends StatefulWidget {
class PhotoViewCoreState extends State<PhotoViewCore> class PhotoViewCoreState extends State<PhotoViewCore>
with TickerProviderStateMixin, PhotoViewControllerDelegate, HitCornersDetector { with TickerProviderStateMixin, PhotoViewControllerDelegate, HitCornersDetector {
Offset? _normalizedPosition;
double? _scaleBefore; double? _scaleBefore;
double? _rotationBefore; double? _rotationBefore;
@@ -153,6 +154,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
void onScaleStart(ScaleStartDetails details) { void onScaleStart(ScaleStartDetails details) {
_rotationBefore = controller.rotation; _rotationBefore = controller.rotation;
_scaleBefore = scale; _scaleBefore = scale;
_normalizedPosition = details.focalPoint - controller.position;
_scaleAnimationController.stop(); _scaleAnimationController.stop();
_positionAnimationController.stop(); _positionAnimationController.stop();
_rotationAnimationController.stop(); _rotationAnimationController.stop();
@@ -164,14 +166,8 @@ class PhotoViewCoreState extends State<PhotoViewCore>
}; };
void onScaleUpdate(ScaleUpdateDetails details) { void onScaleUpdate(ScaleUpdateDetails details) {
final centeredFocalPoint = Offset(
details.focalPoint.dx - scaleBoundaries.outerSize.width / 2,
details.focalPoint.dy - scaleBoundaries.outerSize.height / 2,
);
final double newScale = _scaleBefore! * details.scale; final double newScale = _scaleBefore! * details.scale;
final double scaleDelta = newScale / scale; Offset delta = details.focalPoint - _normalizedPosition!;
final Offset newPosition =
(controller.position + details.focalPointDelta) * scaleDelta - centeredFocalPoint * (scaleDelta - 1);
updateScaleStateFromNewScale(newScale); updateScaleStateFromNewScale(newScale);
@@ -180,7 +176,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
updateMultiple( updateMultiple(
scale: newScale, scale: newScale,
position: panEnabled ? newPosition : clampPosition(position: newPosition), position: panEnabled ? delta : clampPosition(position: delta * details.scale),
rotation: rotationEnabled ? _rotationBefore! + details.rotation : null, rotation: rotationEnabled ? _rotationBefore! + details.rotation : null,
rotationFocusPoint: rotationEnabled ? details.focalPoint : null, rotationFocusPoint: rotationEnabled ? details.focalPoint : null,
); );
@@ -1,153 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
class DriftBackupSettings extends ConsumerWidget { class DriftBackupSettings extends StatelessWidget {
const DriftBackupSettings({super.key}); const DriftBackupSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const SettingsSubPageScaffold(
settings: [
_UseWifiForUploadVideosButton(),
_UseWifiForUploadPhotosButton(),
Divider(indent: 16, endIndent: 16),
_AlbumSyncActionButton(),
],
);
}
}
class _AlbumSyncActionButton extends ConsumerStatefulWidget {
const _AlbumSyncActionButton();
@override
ConsumerState<_AlbumSyncActionButton> createState() => _AlbumSyncActionButtonState();
}
class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> {
bool isAlbumSyncInProgress = false;
Future<void> _manualSyncAlbums() async {
setState(() {
isAlbumSyncInProgress = true;
});
try {
await ref.read(backgroundSyncProvider).syncLinkedAlbum();
await ref.read(backgroundSyncProvider).syncRemote();
} catch (_) {
} finally {
Future.delayed(const Duration(seconds: 1), () {
setState(() {
isAlbumSyncInProgress = false;
});
});
}
}
Future<void> _manageLinkedAlbums() async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
final localAlbums = ref.read(backupAlbumProvider);
final selectedBackupAlbums = localAlbums
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
await ref.read(syncLinkedAlbumServiceProvider).manageLinkedAlbums(selectedBackupAlbums, currentUser.id);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return const SettingsSubPageScaffold(settings: [_UseWifiForUploadVideosButton(), _UseWifiForUploadPhotosButton()]);
shrinkWrap: true,
children: [
StreamBuilder(
stream: Store.watch(StoreKey.syncAlbums),
initialData: Store.tryGet(StoreKey.syncAlbums) ?? false,
builder: (context, snapshot) {
final albumSyncEnable = snapshot.data ?? false;
return Column(
children: [
ListTile(
title: Text(
"sync_albums".t(context: context),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
),
subtitle: Text(
"sync_upload_album_setting_subtitle".t(context: context),
style: context.textTheme.labelLarge,
),
trailing: Switch(
value: albumSyncEnable,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
if (newValue == true) {
await _manageLinkedAlbums();
}
},
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: albumSyncEnable ? 1.0 : 0.0,
child: albumSyncEnable
? ListTile(
onTap: _manualSyncAlbums,
contentPadding: const EdgeInsets.only(left: 32, right: 16),
title: Text(
"organize_into_albums".t(context: context),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.normal,
),
),
subtitle: Text(
"organize_into_albums_description".t(context: context),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
trailing: isAlbumSyncInProgress
? const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: IconButton(
onPressed: _manualSyncAlbums,
icon: const Icon(Icons.sync_rounded),
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
iconSize: 20,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
)
: const SizedBox.shrink(),
),
),
],
);
},
),
],
);
} }
} }
@@ -109,37 +109,6 @@ class BetaSyncSettings extends HookConsumerWidget {
await ref.read(storageRepositoryProvider).clearCache(); await ref.read(storageRepositoryProvider).clearCache();
} }
Future<void> resetSqliteDb(BuildContext context, Future<void> Function() resetDatabase) {
return showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text("reset_sqlite".t(context: context)),
content: Text("reset_sqlite_confirmation".t(context: context)),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text("cancel".t(context: context)),
),
TextButton(
onPressed: () async {
await resetDatabase();
context.pop();
context.scaffoldMessenger.showSnackBar(
SnackBar(content: Text("reset_sqlite_success".t(context: context))),
);
},
child: Text(
"confirm".t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
);
},
);
}
return FutureBuilder<List<dynamic>>( return FutureBuilder<List<dynamic>>(
future: loadCounts(), future: loadCounts(),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -147,33 +116,6 @@ class BetaSyncSettings extends HookConsumerWidget {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
} }
if (snapshot.hasError) {
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: Text(
"Error occur, reset the local database by tapping the button below",
style: context.textTheme.bodyLarge,
),
),
),
ListTile(
title: Text(
"reset_sqlite".t(context: context),
style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500),
),
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
onTap: () async {
await resetSqliteDb(context, resetDatabase);
},
),
],
);
}
final assetCounts = snapshot.data![0]! as (int, int); final assetCounts = snapshot.data![0]! as (int, int);
final localAssetCount = assetCounts.$1; final localAssetCount = assetCounts.$1;
final remoteAssetCount = assetCounts.$2; final remoteAssetCount = assetCounts.$2;
@@ -328,7 +270,34 @@ class BetaSyncSettings extends HookConsumerWidget {
), ),
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
onTap: () async { onTap: () async {
await resetSqliteDb(context, resetDatabase); showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text("reset_sqlite".t(context: context)),
content: Text("reset_sqlite_confirmation".t(context: context)),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text("cancel".t(context: context)),
),
TextButton(
onPressed: () async {
await resetDatabase();
context.pop();
context.scaffoldMessenger.showSnackBar(
SnackBar(content: Text("reset_sqlite_success".t(context: context))),
);
},
child: Text(
"confirm".t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
);
},
);
}, },
), ),
], ],
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.141.0 - API version: 1.140.1
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+2 -2
View File
@@ -18,7 +18,7 @@ class AssetsApi {
/// checkBulkUpload /// checkBulkUpload
/// ///
/// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission. /// Checks if assets exist by checksums
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
/// ///
@@ -52,7 +52,7 @@ class AssetsApi {
/// checkBulkUpload /// checkBulkUpload
/// ///
/// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission. /// Checks if assets exist by checksums
/// ///
/// Parameters: /// Parameters:
/// ///
+6 -32
View File
@@ -31,8 +31,7 @@ class SmartSearchDto {
this.model, this.model,
this.page, this.page,
this.personIds = const [], this.personIds = const [],
this.query, required this.query,
this.queryAssetId,
this.rating, this.rating,
this.size, this.size,
this.state, this.state,
@@ -152,21 +151,7 @@ class SmartSearchDto {
List<String> personIds; List<String> personIds;
/// String query;
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? query;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? queryAssetId;
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
@@ -293,7 +278,6 @@ class SmartSearchDto {
other.page == page && other.page == page &&
_deepEquality.equals(other.personIds, personIds) && _deepEquality.equals(other.personIds, personIds) &&
other.query == query && other.query == query &&
other.queryAssetId == queryAssetId &&
other.rating == rating && other.rating == rating &&
other.size == size && other.size == size &&
other.state == state && other.state == state &&
@@ -330,8 +314,7 @@ class SmartSearchDto {
(model == null ? 0 : model!.hashCode) + (model == null ? 0 : model!.hashCode) +
(page == null ? 0 : page!.hashCode) + (page == null ? 0 : page!.hashCode) +
(personIds.hashCode) + (personIds.hashCode) +
(query == null ? 0 : query!.hashCode) + (query.hashCode) +
(queryAssetId == null ? 0 : queryAssetId!.hashCode) +
(rating == null ? 0 : rating!.hashCode) + (rating == null ? 0 : rating!.hashCode) +
(size == null ? 0 : size!.hashCode) + (size == null ? 0 : size!.hashCode) +
(state == null ? 0 : state!.hashCode) + (state == null ? 0 : state!.hashCode) +
@@ -348,7 +331,7 @@ class SmartSearchDto {
(withExif == null ? 0 : withExif!.hashCode); (withExif == null ? 0 : withExif!.hashCode);
@override @override
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]'; String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -434,16 +417,7 @@ class SmartSearchDto {
// json[r'page'] = null; // json[r'page'] = null;
} }
json[r'personIds'] = this.personIds; json[r'personIds'] = this.personIds;
if (this.query != null) {
json[r'query'] = this.query; json[r'query'] = this.query;
} else {
// json[r'query'] = null;
}
if (this.queryAssetId != null) {
json[r'queryAssetId'] = this.queryAssetId;
} else {
// json[r'queryAssetId'] = null;
}
if (this.rating != null) { if (this.rating != null) {
json[r'rating'] = this.rating; json[r'rating'] = this.rating;
} else { } else {
@@ -548,8 +522,7 @@ class SmartSearchDto {
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
query: mapValueOfType<String>(json, r'query'), query: mapValueOfType<String>(json, r'query')!,
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: num.parse('${json[r'rating']}'), rating: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'), size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
@@ -613,6 +586,7 @@ class SmartSearchDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'query',
}; };
} }
-3
View File
@@ -69,7 +69,6 @@ class SyncEntityType {
static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1'); static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1');
static const syncAckV1 = SyncEntityType._(r'SyncAckV1'); static const syncAckV1 = SyncEntityType._(r'SyncAckV1');
static const syncResetV1 = SyncEntityType._(r'SyncResetV1'); static const syncResetV1 = SyncEntityType._(r'SyncResetV1');
static const syncCompleteV1 = SyncEntityType._(r'SyncCompleteV1');
/// List of all possible values in this [enum][SyncEntityType]. /// List of all possible values in this [enum][SyncEntityType].
static const values = <SyncEntityType>[ static const values = <SyncEntityType>[
@@ -119,7 +118,6 @@ class SyncEntityType {
userMetadataDeleteV1, userMetadataDeleteV1,
syncAckV1, syncAckV1,
syncResetV1, syncResetV1,
syncCompleteV1,
]; ];
static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value);
@@ -204,7 +202,6 @@ class SyncEntityTypeTypeTransformer {
case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1; case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1;
case r'SyncAckV1': return SyncEntityType.syncAckV1; case r'SyncAckV1': return SyncEntityType.syncAckV1;
case r'SyncResetV1': return SyncEntityType.syncResetV1; case r'SyncResetV1': return SyncEntityType.syncResetV1;
case r'SyncCompleteV1': return SyncEntityType.syncCompleteV1;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
+10 -5
View File
@@ -13,9 +13,13 @@ import 'package:pigeon/pigeon.dart';
) )
@HostApi() @HostApi()
abstract class BackgroundWorkerFgHostApi { abstract class BackgroundWorkerFgHostApi {
void enable(); void enableSyncWorker();
void disable(); // Enables the background upload service with the given callback handle
void enableUploadWorker(int callbackHandle);
// Disables the background upload service
void disableUploadWorker();
} }
@HostApi() @HostApi()
@@ -23,13 +27,14 @@ abstract class BackgroundWorkerBgHostApi {
// Called from the background flutter engine when it has bootstrapped and established the // Called from the background flutter engine when it has bootstrapped and established the
// required platform channels to notify the native side to start the background upload // required platform channels to notify the native side to start the background upload
void onInitialized(); void onInitialized();
// Called from the background flutter engine to request the native side to cleanup
void close();
} }
@FlutterApi() @FlutterApi()
abstract class BackgroundWorkerFlutterApi { abstract class BackgroundWorkerFlutterApi {
// Android & iOS: Called when the local sync is triggered
@async
void onLocalSync(int? maxSeconds);
// iOS Only: Called when the iOS background upload is triggered // iOS Only: Called when the iOS background upload is triggered
@async @async
void onIosUpload(bool isRefresh, int? maxSeconds); void onIosUpload(bool isRefresh, int? maxSeconds);
+42 -2
View File
@@ -337,6 +337,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
cronet_http:
dependency: "direct main"
description:
name: cronet_http
sha256: "1b99ad5ae81aa9d2f12900e5f17d3681f3828629bb7f7fe7ad88076a34209840"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
crop_image: crop_image:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -369,6 +377,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.2" version: "1.0.2"
cupertino_http:
dependency: "direct main"
description:
name: cupertino_http
sha256: "72187f715837290a63479a5b0ae709f4fedad0ed6bd0441c275eceaa02d5abae"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
custom_lint: custom_lint:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -899,10 +915,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: http name: http
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.5.0"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -919,6 +935,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
http_profile:
dependency: transitive
description:
name: http_profile
sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
image: image:
dependency: transitive dependency: transitive
description: description:
@@ -1044,6 +1068,14 @@ packages:
url: "https://github.com/immich-app/isar" url: "https://github.com/immich-app/isar"
source: git source: git
version: "3.1.8" version: "3.1.8"
jni:
dependency: transitive
description:
name: jni
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
url: "https://pub.dev"
source: hosted
version: "0.14.2"
js: js:
dependency: transitive dependency: transitive
description: description:
@@ -1237,6 +1269,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.0" version: "0.5.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "9f034ba1eeca53ddb339bc8f4813cb07336a849cd735559b60cdc068ecce2dc7"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
octo_image: octo_image:
dependency: "direct main" dependency: "direct main"
description: description:
+3 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.141.0+3012 version: 1.140.1+3011
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
@@ -90,6 +90,8 @@ dependencies:
# DB # DB
drift: ^2.23.1 drift: ^2.23.1
drift_flutter: ^0.2.4 drift_flutter: ^0.2.4
cronet_http: ^1.5.0
cupertino_http: ^2.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+1 -4
View File
@@ -11,7 +11,6 @@ import 'schema_v5.dart' as v5;
import 'schema_v6.dart' as v6; import 'schema_v6.dart' as v6;
import 'schema_v7.dart' as v7; import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8; import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -33,12 +32,10 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v7.DatabaseAtV7(db); return v7.DatabaseAtV7(db);
case 8: case 8:
return v8.DatabaseAtV8(db); return v8.DatabaseAtV8(db);
case 9:
return v9.DatabaseAtV9(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9]; static const versions = const [1, 2, 3, 4, 5, 6, 7, 8];
} }
File diff suppressed because it is too large Load Diff
@@ -1,58 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
void main() {
group('tryFromSecondsSinceEpoch', () {
test('returns null for null input', () {
final result = tryFromSecondsSinceEpoch(null);
expect(result, isNull);
});
test('returns null for value below minimum allowed range', () {
// _minMillisecondsSinceEpoch = -62135596800000
final seconds = -62135596800000 ~/ 1000 - 1; // One second before min allowed
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, isNull);
});
test('returns null for value above maximum allowed range', () {
// _maxMillisecondsSinceEpoch = 8640000000000000
final seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, isNull);
});
test('returns correct DateTime for minimum allowed value', () {
final seconds = -62135596800000 ~/ 1000; // Minimum allowed timestamp
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, DateTime.fromMillisecondsSinceEpoch(-62135596800000));
});
test('returns correct DateTime for maximum allowed value', () {
final seconds = 8640000000000000 ~/ 1000; // Maximum allowed timestamp
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, DateTime.fromMillisecondsSinceEpoch(8640000000000000));
});
test('returns correct DateTime for negative timestamp', () {
final seconds = -1577836800; // Dec 31, 1919 (pre-epoch)
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, DateTime.fromMillisecondsSinceEpoch(-1577836800 * 1000));
});
test('returns correct DateTime for zero timestamp', () {
final seconds = 0; // Jan 1, 1970 (epoch)
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, DateTime.fromMillisecondsSinceEpoch(0));
});
test('returns correct DateTime for recent timestamp', () {
final now = DateTime.now();
final seconds = now.millisecondsSinceEpoch ~/ 1000;
final result = tryFromSecondsSinceEpoch(seconds);
expect(result?.year, now.year);
expect(result?.month, now.month);
expect(result?.day, now.day);
});
});
}
+7 -14
View File
@@ -1855,7 +1855,7 @@
}, },
"/assets/bulk-upload-check": { "/assets/bulk-upload-check": {
"post": { "post": {
"description": "Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.", "description": "Checks if assets exist by checksums",
"operationId": "checkBulkUpload", "operationId": "checkBulkUpload",
"parameters": [], "parameters": [],
"requestBody": { "requestBody": {
@@ -1894,8 +1894,7 @@
"summary": "checkBulkUpload", "summary": "checkBulkUpload",
"tags": [ "tags": [
"Assets" "Assets"
], ]
"x-immich-permission": "asset.upload"
} }
}, },
"/assets/device/{deviceId}": { "/assets/device/{deviceId}": {
@@ -9790,7 +9789,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.141.0", "version": "1.140.1",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
@@ -14571,10 +14570,6 @@
"query": { "query": {
"type": "string" "type": "string"
}, },
"queryAssetId": {
"format": "uuid",
"type": "string"
},
"rating": { "rating": {
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
@@ -14642,6 +14637,9 @@
"type": "boolean" "type": "boolean"
} }
}, },
"required": [
"query"
],
"type": "object" "type": "object"
}, },
"SourceType": { "SourceType": {
@@ -15417,10 +15415,6 @@
], ],
"type": "object" "type": "object"
}, },
"SyncCompleteV1": {
"properties": {},
"type": "object"
},
"SyncEntityType": { "SyncEntityType": {
"enum": [ "enum": [
"AuthUserV1", "AuthUserV1",
@@ -15468,8 +15462,7 @@
"UserMetadataV1", "UserMetadataV1",
"UserMetadataDeleteV1", "UserMetadataDeleteV1",
"SyncAckV1", "SyncAckV1",
"SyncResetV1", "SyncResetV1"
"SyncCompleteV1"
], ],
"type": "string" "type": "string"
}, },
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.141.0", "version": "1.140.1",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.18.0", "@types/node": "^22.17.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {
+3 -5
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.141.0 * 1.140.1
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
@@ -1014,8 +1014,7 @@ export type SmartSearchDto = {
model?: string | null; model?: string | null;
page?: number; page?: number;
personIds?: string[]; personIds?: string[];
query?: string; query: string;
queryAssetId?: string;
rating?: number; rating?: number;
size?: number; size?: number;
state?: string | null; state?: string | null;
@@ -4922,8 +4921,7 @@ export enum SyncEntityType {
UserMetadataV1 = "UserMetadataV1", UserMetadataV1 = "UserMetadataV1",
UserMetadataDeleteV1 = "UserMetadataDeleteV1", UserMetadataDeleteV1 = "UserMetadataDeleteV1",
SyncAckV1 = "SyncAckV1", SyncAckV1 = "SyncAckV1",
SyncResetV1 = "SyncResetV1", SyncResetV1 = "SyncResetV1"
SyncCompleteV1 = "SyncCompleteV1"
} }
export enum SyncRequestType { export enum SyncRequestType {
AlbumsV1 = "AlbumsV1", AlbumsV1 = "AlbumsV1",
+1112 -553
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -27,7 +27,7 @@ onlyBuiltDependencies:
- '@tailwindcss/oxide' - '@tailwindcss/oxide'
overrides: overrides:
canvas: 2.11.2 canvas: 2.11.2
sharp: ^0.34.3 sharp: ^0.34.2
packageExtensions: packageExtensions:
nestjs-kysely: nestjs-kysely:
dependencies: dependencies:
+3 -3
View File
@@ -1,5 +1,5 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS dev FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
@@ -77,7 +77,7 @@ RUN apt-get update \
RUN dart --disable-analytics RUN dart --disable-analytics
# production-builder-base image # production-builder-base image
FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS prod-builder-base FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS prod-builder-base
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
COREPACK_HOME=/tmp COREPACK_HOME=/tmp
@@ -115,7 +115,7 @@ RUN pnpm --filter @immich/sdk --filter @immich/cli --frozen-lockfile install &&
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
# prod base image # prod base image
FROM ghcr.io/immich-app/base-server-prod:202509021104@sha256:84f3727cff75c623f79236cdd9a2b72c84f7665057f474851016f702c67157af FROM ghcr.io/immich-app/base-server-prod:202508191104@sha256:4cce4119f5555fce5e383b681e4feea31956ceadb94cafcbcbbae2c7b94a1b62
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.141.0", "version": "1.140.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -103,7 +103,7 @@
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0", "sanitize-html": "^2.14.0",
"semver": "^7.6.2", "semver": "^7.6.2",
"sharp": "^0.34.3", "sharp": "^0.34.2",
"sirv": "^3.0.0", "sirv": "^3.0.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0", "tailwindcss-preset-email": "^1.4.0",
@@ -135,7 +135,7 @@
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.18.0", "@types/node": "^22.13.14",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^4.0.0", "@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
@@ -176,6 +176,6 @@
"node": "22.18.0" "node": "22.18.0"
}, },
"overrides": { "overrides": {
"sharp": "^0.34.3" "sharp": "^0.34.2"
} }
} }
@@ -1,6 +1,4 @@
import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AssetMetadataKey } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import request from 'supertest'; import request from 'supertest';
@@ -13,7 +11,7 @@ const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
deviceId: 'TEST', deviceId: 'TEST',
fileCreatedAt: new Date().toISOString(), fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(), fileModifiedAt: new Date().toISOString(),
isFavorite: 'false', isFavorite: 'testing',
duration: '0:00:00.000000', duration: '0:00:00.000000',
}; };
@@ -29,20 +27,16 @@ describe(AssetMediaController.name, () => {
let ctx: ControllerContext; let ctx: ControllerContext;
const assetData = Buffer.from('123'); const assetData = Buffer.from('123');
const filename = 'example.png'; const filename = 'example.png';
const service = mockBaseService(AssetMediaService);
beforeAll(async () => { beforeAll(async () => {
ctx = await controllerSetup(AssetMediaController, [ ctx = await controllerSetup(AssetMediaController, [
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
{ provide: AssetMediaService, useValue: service }, { provide: AssetMediaService, useValue: mockBaseService(AssetMediaService) },
]); ]);
return () => ctx.close(); return () => ctx.close();
}); });
beforeEach(() => { beforeEach(() => {
service.resetAllMocks();
service.uploadAsset.mockResolvedValue({ status: AssetMediaStatus.DUPLICATE, id: factory.uuid() });
ctx.reset(); ctx.reset();
}); });
@@ -52,61 +46,13 @@ describe(AssetMediaController.name, () => {
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
it('should accept metadata', async () => {
const mobileMetadata = { key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } };
const { status } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([mobileMetadata]),
});
expect(service.uploadAsset).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ metadata: [mobileMetadata] }),
expect.objectContaining({ originalName: 'example.png' }),
undefined,
);
expect(status).toBe(200);
});
it('should handle invalid metadata json', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: 'not-a-string-string',
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
});
it('should validate iCloudId is a string', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 123 } }]),
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata.0.value.iCloudId must be a string']));
});
it('should require `deviceAssetId`', async () => { it('should require `deviceAssetId`', async () => {
const { status, body } = await request(ctx.getHttpServer()) const { status, body } = await request(ctx.getHttpServer())
.post('/assets') .post('/assets')
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) }); .field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest());
factory.responses.badRequest(['deviceAssetId must be a string', 'deviceAssetId should not be empty']),
);
}); });
it('should require `deviceId`', async () => { it('should require `deviceId`', async () => {
@@ -115,7 +61,7 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceId' }) }); .field({ ...makeUploadDto({ omit: 'deviceId' }) });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['deviceId must be a string', 'deviceId should not be empty'])); expect(body).toEqual(factory.responses.badRequest());
}); });
it('should require `fileCreatedAt`', async () => { it('should require `fileCreatedAt`', async () => {
@@ -124,20 +70,25 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) }); .field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest());
factory.responses.badRequest(['fileCreatedAt must be a Date instance', 'fileCreatedAt should not be empty']),
);
}); });
it('should require `fileModifiedAt`', async () => { it('should require `fileModifiedAt`', async () => {
const { status, body } = await request(ctx.getHttpServer()) const { status, body } = await request(ctx.getHttpServer())
.post('/assets') .post('/assets')
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field(makeUploadDto({ omit: 'fileModifiedAt' })); .field({ ...makeUploadDto({ omit: 'fileModifiedAt' }) });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest());
factory.responses.badRequest(['fileModifiedAt must be a Date instance', 'fileModifiedAt should not be empty']), });
);
it('should require `duration`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'duration' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
}); });
it('should throw if `isFavorite` is not a boolean', async () => { it('should throw if `isFavorite` is not a boolean', async () => {
@@ -146,18 +97,16 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' }); .field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value'])); expect(body).toEqual(factory.responses.badRequest());
}); });
it('should throw if `visibility` is not an enum', async () => { it('should throw if `visibility` is not an enum', async () => {
const { status, body } = await request(ctx.getHttpServer()) const { status, body } = await request(ctx.getHttpServer())
.post('/assets') .post('/assets')
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), visibility: 'not-an-option' }); .field({ ...makeUploadDto(), visibility: 'not-a-boolean' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest());
factory.responses.badRequest([expect.stringContaining('visibility must be one of the following values:')]),
);
}); });
// TODO figure out how to deal with `sendFile` // TODO figure out how to deal with `sendFile`
@@ -188,7 +188,7 @@ export class AssetMediaController {
* Checks if assets exist by checksums * Checks if assets exist by checksums
*/ */
@Post('bulk-upload-check') @Post('bulk-upload-check')
@Authenticated({ permission: Permission.AssetUpload }) @Authenticated()
@ApiOperation({ @ApiOperation({
summary: 'checkBulkUpload', summary: 'checkBulkUpload',
description: 'Checks if assets exist by checksums', description: 'Checks if assets exist by checksums',

Some files were not shown because too many files have changed in this diff Show More