Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state

This commit is contained in:
Peter Ombodi 2025-09-05 17:47:17 +03:00
commit f7573ae317
145 changed files with 11086 additions and 2627 deletions

2
.github/.nvmrc vendored
View file

@ -1 +1 @@
22.18.0
22.19.0

View file

@ -34,3 +34,7 @@ The `/api/something` endpoint is now `/api/something-else`
- [ ] 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/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.
...

View file

@ -10,14 +10,14 @@ dev-update: 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
dev-docs: prepare-volumes
dev-docs:
npm --prefix docs run start
.PHONY: e2e
e2e: prepare-volumes
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
e2e-update: prepare-volumes
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-down:
@ -73,6 +73,8 @@ define safe_chown
if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \
true; \
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."; \
exit 1; \
fi;
@ -83,11 +85,13 @@ prepare-volumes:
@$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R))
ifneq ($(UPLOAD_LOCATION),)
ifeq ($(filter /%,$(UPLOAD_LOCATION)),)
@mkdir -p "docker/$(UPLOAD_LOCATION)"
@mkdir -p "docker/$(UPLOAD_LOCATION)/photos/upload"
@$(call safe_chown,docker/$(UPLOAD_LOCATION),)
@$(call safe_chown,docker/$(UPLOAD_LOCATION)/photos,-R)
else
@mkdir -p "$(UPLOAD_LOCATION)"
@mkdir -p "$(UPLOAD_LOCATION)/photos/upload"
@$(call safe_chown,$(UPLOAD_LOCATION),)
@$(call safe_chown,$(UPLOAD_LOCATION)/photos,-R)
endif
endif

View file

@ -1 +1 @@
22.18.0
22.19.0

View file

@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.86",
"version": "2.2.87",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.17.1",
"@types/node": "^22.18.0",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "22.18.0"
"node": "22.19.0"
}
}

View file

@ -1 +1 @@
22.18.0
22.19.0

View file

@ -60,6 +60,6 @@
"node": ">=20"
},
"volta": {
"node": "22.18.0"
"node": "22.19.0"
}
}

View file

@ -1,4 +1,8 @@
[
{
"label": "v1.141.0",
"url": "https://v1.141.0.archive.immich.app"
},
{
"label": "v1.140.1",
"url": "https://v1.140.1.archive.immich.app"

View file

@ -1 +1 @@
22.18.0
22.19.0

View file

@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.140.1",
"version": "1.141.0",
"description": "",
"main": "index.js",
"type": "module",
@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.17.1",
"@types/node": "^22.18.0",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
@ -45,7 +45,7 @@
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.0",
"sharp": "^0.34.3",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
@ -54,6 +54,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "22.18.0"
"node": "22.19.0"
}
}

View file

@ -1417,6 +1417,8 @@
"open_the_search_filters": "Open the search filters",
"options": "Options",
"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",
"original": "original",
"other": "Other",
@ -1557,6 +1559,7 @@
"purchase_server_description_2": "Supporter status",
"purchase_server_title": "Server",
"purchase_settings_server_activated": "The server product key is managed by the admin",
"query_asset_id": "Query Asset ID",
"queue_status": "Queuing {count}/{total}",
"rating": "Star rating",
"rating_clear": "Clear rating",
@ -1735,7 +1738,7 @@
"select_user_for_sharing_page_err_album": "Failed to create album",
"selected": "Selected",
"selected_count": "{count, plural, other {# selected}}",
"selected_gps_coordinates": "selected gps coordinates",
"selected_gps_coordinates": "Selected GPS Coordinates",
"send_message": "Send message",
"send_welcome_email": "Send welcome email",
"server_endpoint": "Server Endpoint",
@ -2077,6 +2080,7 @@
"view_next_asset": "View next asset",
"view_previous_asset": "View previous asset",
"view_qr_code": "View QR code",
"view_similar_photos": "View similar photos",
"view_stack": "View Stack",
"view_user": "View User",
"viewer_remove_from_stack": "Remove from Stack",

34
mise.lock Normal file
View file

@ -0,0 +1,34 @@
[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"

312
mise.toml Normal file
View file

@ -0,0 +1,312 @@
[tools]
node = "22.19.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
# .github
[tasks."github:install"]
run = "pnpm install --filter github --frozen-lockfile"
[tasks."github:format"]
env._.path = "./.github/node_modules/.bin"
dir = ".github"
run = "prettier --check ."
[tasks."github:format-fix"]
env._.path = "./.github/node_modules/.bin"
dir = ".github"
run = "prettier --write ."
# @immich/cli
[tasks."cli:install"]
run = "pnpm install --filter @immich/cli --frozen-lockfile"
[tasks."cli:build"]
env._.path = "./cli/node_modules/.bin"
dir = "cli"
run = "vite build"
[tasks."cli:test"]
env._.path = "./cli/node_modules/.bin"
dir = "cli"
run = "vite"
[tasks."cli:lint"]
env._.path = "./cli/node_modules/.bin"
dir = "cli"
run = "eslint \"src/**/*.ts\" --max-warnings 0"
[tasks."cli:lint-fix"]
run = "mise run cli:lint --fix"
[tasks."cli:format"]
env._.path = "./cli/node_modules/.bin"
dir = "cli"
run = "prettier --check ."
[tasks."cli:format-fix"]
env._.path = "./cli/node_modules/.bin"
dir = "cli"
run = "prettier --write ."
[tasks."cli:check"]
env._.path = "./cli/node_modules/.bin"
dir = "cli"
run = "tsc --noEmit"
# @immich/sdk
[tasks."sdk:install"]
run = "pnpm install --filter @immich/sdk --frozen-lockfile"
[tasks."sdk:build"]
env._.path = "./open-api/typescript-sdk/node_modules/.bin"
dir = "./open-api/typescript-sdk"
run = "tsc"
# docs
[tasks."docs:install"]
run = "pnpm install --filter documentation --frozen-lockfile"
[tasks."docs:start"]
env._.path = "./docs/node_modules/.bin"
dir = "docs"
run = "docusaurus --port 3005"
[tasks."docs:build"]
env._.path = "./docs/node_modules/.bin"
dir = "docs"
run = [
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"docusaurus build",
]
[tasks."docs:preview"]
env._.path = "./docs/node_modules/.bin"
dir = "docs"
run = "docusaurus serve"
[tasks."docs:format"]
env._.path = "./docs/node_modules/.bin"
dir = "docs"
run = "prettier --check ."
[tasks."docs:format-fix"]
env._.path = "./docs/node_modules/.bin"
dir = "docs"
run = "prettier --write ."
# e2e
[tasks."e2e:install"]
run = "pnpm install --filter immich-e2e --frozen-lockfile"
[tasks."e2e:test"]
env._.path = "./e2e/node_modules/.bin"
dir = "e2e"
run = "vitest --run"
[tasks."e2e:test-web"]
env._.path = "./e2e/node_modules/.bin"
dir = "e2e"
run = "playwright test"
[tasks."e2e:format"]
env._.path = "./e2e/node_modules/.bin"
dir = "e2e"
run = "prettier --check ."
[tasks."e2e:format-fix"]
env._.path = "./e2e/node_modules/.bin"
dir = "e2e"
run = "prettier --write ."
[tasks."e2e:lint"]
env._.path = "./e2e/node_modules/.bin"
dir = "e2e"
run = "eslint \"src/**/*.ts\" --max-warnings 0"
[tasks."e2e:lint-fix"]
run = "mise run e2e:lint --fix"
[tasks."e2e:check"]
env._.path = "./e2e/node_modules/.bin"
dir = "e2e"
run = "tsc --noEmit"
# i18n
[tasks."i18n:format"]
run = "mise run i18n:format-fix"
[tasks."i18n:format-fix"]
run = "pnpm dlx sort-json ./i18n/*.json"
# server
[tasks."server:install"]
run = "pnpm install --filter immich --frozen-lockfile"
[tasks."server:build"]
env._.path = "./server/node_modules/.bin"
dir = "server"
run = "nest build"
[tasks."server:test"]
env._.path = "./server/node_modules/.bin"
dir = "server"
run = "vitest --config test/vitest.config.mjs"
[tasks."server:test-medium"]
env._.path = "./server/node_modules/.bin"
dir = "server"
run = "vitest --config test/vitest.config.medium.mjs"
[tasks."server:format"]
env._.path = "./server/node_modules/.bin"
dir = "server"
run = "prettier --check ."
[tasks."server:format-fix"]
env._.path = "./server/node_modules/.bin"
dir = "server"
run = "prettier --write ."
[tasks."server:lint"]
env._.path = "./server/node_modules/.bin"
dir = "server"
run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0"
[tasks."server:lint-fix"]
run = "mise run server:lint --fix"
[tasks."server:check"]
env._.path = "./server/node_modules/.bin"
dir = "server"
run = "tsc --noEmit"
[tasks."server:sql"]
dir = "server"
run = "node ./dist/bin/sync-open-api.js"
[tasks."server:open-api"]
dir = "server"
run = "node ./dist/bin/sync-open-api.js"
[tasks."server:migrations"]
dir = "server"
run = "node ./dist/bin/migrations.js"
description = "Run database migration commands (create, generate, run, debug, or query)"
[tasks."server:schema-drop"]
run = "mise run server:migrations query 'DROP schema public cascade; CREATE schema public;'"
[tasks."server:schema-reset"]
run = "mise run server:schema-drop && mise run server:migrations run"
[tasks."server:email-dev"]
env._.path = "./server/node_modules/.bin"
dir = "server"
run = "email dev -p 3050 --dir src/emails"
[tasks."server:checklist"]
run = [
"mise run server:install",
"mise run server:format",
"mise run server:lint",
"mise run server:check",
"mise run server:test-medium --run",
"mise run server:test --run",
]
# web
[tasks."web:install"]
run = "pnpm install --filter immich-web --frozen-lockfile"
[tasks."web:svelte-kit-sync"]
env._.path = "./web/node_modules/.bin"
dir = "web"
run = "svelte-kit sync"
[tasks."web:build"]
env._.path = "./web/node_modules/.bin"
dir = "web"
run = "vite build"
[tasks."web:build-stats"]
env.BUILD_STATS = "true"
env._.path = "./web/node_modules/.bin"
dir = "web"
run = "vite build"
[tasks."web:preview"]
env._.path = "./web/node_modules/.bin"
dir = "web"
run = "vite preview"
[tasks."web:start"]
env._.path = "web/node_modules/.bin"
dir = "web"
run = "vite dev --host 0.0.0.0 --port 3000"
[tasks."web:test"]
depends = "web:svelte-kit-sync"
env._.path = "web/node_modules/.bin"
dir = "web"
run = "vitest"
[tasks."web:format"]
env._.path = "web/node_modules/.bin"
dir = "web"
run = "prettier --check ."
[tasks."web:format-fix"]
env._.path = "web/node_modules/.bin"
dir = "web"
run = "prettier --write ."
[tasks."web:lint"]
env._.path = "web/node_modules/.bin"
dir = "web"
run = "eslint . --max-warnings 0"
[tasks."web:lint-p"]
env._.path = "web/node_modules/.bin"
dir = "web"
run = "eslint-p . --max-warnings 0 --concurrency=4"
[tasks."web:lint-fix"]
run = "mise run web:lint --fix"
[tasks."web:check"]
depends = "web:svelte-kit-sync"
env._.path = "web/node_modules/.bin"
dir = "web"
run = "tsc --noEmit"
[tasks."web:check-svelte"]
depends = "web:svelte-kit-sync"
env._.path = "web/node_modules/.bin"
dir = "web"
run = "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte"
[tasks."web:checklist"]
run = [
"mise run web:install",
"mise run web:format",
"mise run web:check",
"mise run web:test --run",
"mise run web:lint",
]

View file

@ -61,9 +61,8 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerFgHostApi {
fun enableSyncWorker()
fun enableUploadWorker()
fun disableUploadWorker()
fun enable()
fun disable()
companion object {
/** The codec used by BackgroundWorkerFgHostApi. */
@ -75,11 +74,11 @@ interface BackgroundWorkerFgHostApi {
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.enableSyncWorker()
api.enable()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
@ -91,27 +90,11 @@ interface BackgroundWorkerFgHostApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.enableUploadWorker()
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) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.disableUploadWorker()
api.disable()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
@ -182,23 +165,6 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p
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)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""

View file

@ -16,11 +16,6 @@ import io.flutter.embedding.engine.loader.FlutterLoader
private const val TAG = "BackgroundWorker"
enum class BackgroundTaskType {
LOCAL_SYNC,
UPLOAD,
}
class BackgroundWorker(context: Context, params: WorkerParameters) :
ListenableWorker(context, params), BackgroundWorkerBgHostApi {
private val ctx: Context = context.applicationContext
@ -84,13 +79,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
* This method acts as a bridge between the native Android background task system and Flutter.
*/
override fun onInitialized() {
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) }
}
flutterApi?.onAndroidUpload { handleHostResult(it) }
}
override fun close() {
@ -141,8 +130,10 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
* - Parameter success: Indicates whether the background task completed successfully
*/
private fun complete(success: Result) {
Log.d(TAG, "About to complete BackupWorker with result: $success")
isComplete = true
engine?.destroy()
engine = null
flutterApi = null
completionHandler.set(success)
}

View file

@ -3,10 +3,8 @@ package app.alextran.immich.background
import android.content.Context
import android.provider.MediaStore
import android.util.Log
import androidx.core.content.edit
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
@ -16,18 +14,13 @@ private const val TAG = "BackgroundUploadImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
override fun enableSyncWorker() {
override fun enable() {
enqueueMediaObserver(ctx)
Log.i(TAG, "Scheduled media observer")
}
override fun enableUploadWorker() {
updateUploadEnabled(ctx, true)
Log.i(TAG, "Scheduled background upload tasks")
}
override fun disableUploadWorker() {
updateUploadEnabled(ctx, false)
override fun disable() {
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
Log.i(TAG, "Cancelled background upload tasks")
}
@ -36,25 +29,14 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
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"
private fun updateUploadEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
putBoolean(SHARED_PREF_BACKUP_ENABLED, enabled)
}
}
fun enqueueMediaObserver(ctx: Context) {
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(5, TimeUnit.SECONDS)
.setTriggerContentMaxDelay(1, TimeUnit.MINUTES)
.setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
.build()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
@ -66,15 +48,13 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
}
fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) {
fun enqueueBackgroundWorker(ctx: Context) {
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)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.setInputData(data.build()).build()
.build()
WorkManager.getInstance(ctx)
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)

View file

@ -6,29 +6,17 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
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 {
Log.i("MediaObserver", "Content change detected, starting background worker")
override fun doWork(): Result {
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
if (triggeredContentUris.isNotEmpty()) {
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)
// Enqueue backup worker only if there are new media changes
if (triggeredContentUris.isNotEmpty()) {
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx)
}
return Result.success()
}
}

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3011,
"android.injected.version.name" => "1.140.1",
"android.injected.version.code" => 3012,
"android.injected.version.name" => "1.141.0",
}
)
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

View file

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -507,14 +507,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@ -543,14 +539,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";

View file

@ -73,9 +73,8 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enableSyncWorker() throws
func enableUploadWorker() throws
func disableUploadWorker() throws
func enable() throws
func disable() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@ -84,44 +83,31 @@ class BackgroundWorkerFgHostApiSetup {
/// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let enableSyncWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
enableSyncWorkerChannel.setMessageHandler { _, reply in
enableChannel.setMessageHandler { _, reply in
do {
try api.enableSyncWorker()
try api.enable()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
enableSyncWorkerChannel.setMessageHandler(nil)
enableChannel.setMessageHandler(nil)
}
let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
enableUploadWorkerChannel.setMessageHandler { _, reply in
disableChannel.setMessageHandler { _, reply in
do {
try api.enableUploadWorker()
try api.disable()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
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)
disableChannel.setMessageHandler(nil)
}
}
}
@ -167,7 +153,6 @@ class BackgroundWorkerBgHostApiSetup {
}
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
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 onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void)
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void)
@ -182,24 +167,6 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
var codec: BackgroundWorkerPigeonCodec {
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) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)

View file

@ -1,7 +1,7 @@
import BackgroundTasks
import Flutter
enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
enum BackgroundTaskType { case refresh, processing }
/*
* DEBUG: Testing Background Tasks in Xcode
@ -9,10 +9,6 @@ enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
* To test background task functionality during development:
* 1. Pause the application in Xcode debugger
* 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):
@ -24,8 +20,6 @@ enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
* 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.processingUpload"]
@ -120,19 +114,11 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* This method acts as a bridge between the native iOS background task system and Flutter.
*/
func onInitialized() throws {
switch self.taskType {
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)
})
}
flutterApi?.onIosUpload(isRefresh: self.taskType == .refresh, 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.
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
@ -154,6 +140,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
self.complete(success: false)
}
}
/**
* Handles the result from Flutter API calls and determines the success/failure status.
@ -177,6 +164,10 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* - Parameter success: Indicates whether the background task completed successfully
*/
private func complete(success: Bool) {
if(isComplete) {
return
}
isComplete = true
engine.destroyContext()
completionHandler(success)

View file

@ -1,77 +1,40 @@
import BackgroundTasks
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enableSyncWorker() throws {
BackgroundWorkerApiImpl.scheduleLocalSync()
print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled")
}
func enableUploadWorker() throws {
BackgroundWorkerApiImpl.updateUploadEnabled(true)
BackgroundWorkerApiImpl.scheduleRefreshUpload()
BackgroundWorkerApiImpl.scheduleProcessingUpload()
print("BackgroundUploadImpl:enableUploadWorker Scheduled background upload tasks")
}
func disableUploadWorker() throws {
BackgroundWorkerApiImpl.updateUploadEnabled(false)
BackgroundWorkerApiImpl.cancelUploadTasks()
print("BackgroundUploadImpl:disableUploadWorker Disabled background upload tasks")
}
public static let backgroundUploadEnabledKey = "immich:background:backup:enabled"
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"
private static func updateUploadEnabled(_ isEnabled: Bool) {
return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey)
func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundUploadImpl:enbale Background worker scheduled")
}
private static func cancelUploadTasks() {
BackgroundWorkerApiImpl.updateUploadEnabled(false)
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID);
func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
print("BackgroundUploadImpl:disableUploadWorker Disabled background workers")
}
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
public static func registerBackgroundWorkers() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in
forTaskWithIdentifier: processingTaskID, using: nil) { task in
if task is BGProcessingTask {
handleBackgroundProcessing(task: task as! BGProcessingTask)
}
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload)
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
}
}
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 scheduleRefreshUpload() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID)
private static func scheduleRefreshWorker() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshTaskID)
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
do {
@ -81,8 +44,8 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
}
}
private static func scheduleProcessingUpload() {
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID)
private static func scheduleProcessingWorker() {
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingTaskID)
backgroundProcessing.requiresNetworkConnectivity = true
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
@ -94,29 +57,16 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
}
}
private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) {
let maxSeconds: Int?
switch taskType {
case .localSync:
maxSeconds = 15
scheduleLocalSync()
case .refreshUpload:
maxSeconds = 20
scheduleRefreshUpload()
case .processingUpload:
print("Unexpected background refresh task encountered")
return;
}
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
scheduleRefreshWorker()
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
runBackgroundWorker(task: task, taskType: taskType, maxSeconds: maxSeconds)
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
}
private static func handleBackgroundProcessing(task: BGProcessingTask) {
scheduleProcessingUpload()
scheduleProcessingWorker()
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil)
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
}
/**

View file

@ -46,6 +46,23 @@ class ThumbnailApiImpl: ThumbnailApi {
assetCache.countLimit = 10000
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) {
Self.processingQueue.async {
@ -53,6 +70,7 @@ class ThumbnailApiImpl: ThumbnailApi {
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
}
}
@ -142,6 +160,7 @@ class ThumbnailApiImpl: ThumbnailApi {
return completion(Self.cancelledResult)
}
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId)
}
@ -184,4 +203,9 @@ class ThumbnailApiImpl: ThumbnailApi {
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset
}
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
}

View file

@ -1,190 +1,189 @@
<?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">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.background.localSync</string>
<string>app.alextran.immich.background.refreshUpload</string>
<string>app.alextran.immich.background.processingUpload</string>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.140.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Share Extension</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>immich</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>219</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true />
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.background.refreshUpload</string>
<string>app.alextran.immich.background.processingUpload</string>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.140.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Share Extension</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>immich</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>219</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<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>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false />
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
</dict>
</plist>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
</plist>

View file

@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.140.1"
version_number: "1.141.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View file

@ -15,6 +15,7 @@ class LocalAlbum {
final int assetCount;
final BackupSelection backupSelection;
final String? linkedRemoteAlbumId;
const LocalAlbum({
required this.id,
@ -23,6 +24,7 @@ class LocalAlbum {
this.assetCount = 0,
this.backupSelection = BackupSelection.none,
this.isIosSharedAlbum = false,
this.linkedRemoteAlbumId,
});
LocalAlbum copyWith({
@ -32,6 +34,7 @@ class LocalAlbum {
int? assetCount,
BackupSelection? backupSelection,
bool? isIosSharedAlbum,
String? linkedRemoteAlbumId,
}) {
return LocalAlbum(
id: id ?? this.id,
@ -40,6 +43,7 @@ class LocalAlbum {
assetCount: assetCount ?? this.assetCount,
backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
);
}
@ -53,7 +57,8 @@ class LocalAlbum {
other.updatedAt == updatedAt &&
other.assetCount == assetCount &&
other.backupSelection == backupSelection &&
other.isIosSharedAlbum == isIosSharedAlbum;
other.isIosSharedAlbum == isIosSharedAlbum &&
other.linkedRemoteAlbumId == linkedRemoteAlbumId;
}
@override
@ -63,7 +68,8 @@ class LocalAlbum {
updatedAt.hashCode ^
assetCount.hashCode ^
backupSelection.hashCode ^
isIosSharedAlbum.hashCode;
isIosSharedAlbum.hashCode ^
linkedRemoteAlbumId.hashCode;
}
@override
@ -75,6 +81,7 @@ updatedAt: $updatedAt,
assetCount: $assetCount,
backupSelection: $backupSelection,
isIosSharedAlbum: $isIosSharedAlbum
linkedRemoteAlbumId: $linkedRemoteAlbumId,
}''';
}
}

View file

@ -5,6 +5,7 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/logger_db.repository.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
@ -30,11 +31,9 @@ class BackgroundWorkerFgService {
const BackgroundWorkerFgService(this._foregroundHostApi);
// TODO: Move this call to native side once old timeline is removed
Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker();
Future<void> enable() => _foregroundHostApi.enable();
Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker();
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
Future<void> disable() => _foregroundHostApi.disable();
}
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@ -43,7 +42,8 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift;
final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi;
final Logger _logger = Logger('BackgroundWorkerBgService');
final Logger _logger = Logger('BackgroundUploadBgService');
late final IsolateLockManager _lockManager;
bool _isCleanedUp = false;
@ -59,6 +59,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
driftProvider.overrideWith(driftOverride(drift)),
],
);
_lockManager = IsolateLockManager(onCloseRequest: _cleanup);
BackgroundWorkerFlutterApi.setUp(this);
}
@ -82,41 +83,31 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
await FileDownloader().trackTasks();
configureFileDownloaderNotifications();
await _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();
// Notify the host that the background worker service has been initialized and is ready to use
_backgroundHostApi.onInitialized();
// Notify the host that the background upload service has been initialized and is ready to use
debugPrint("Acquiring background worker lock");
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 {
try {
_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");
} catch (error, stack) {
_logger.severe("Failed to complete local sync", error, stack);
} finally {
await _cleanup();
}
}
/* 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
Future<void> onAndroidUpload() async {
try {
@ -135,14 +126,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
}
/* 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
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
try {
@ -194,7 +177,8 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
await _drift.close();
await _driftLogger.close();
_ref.dispose();
debugPrint("Background worker cleaned up");
_lockManager.releaseLock();
_logger.info("Background worker resources cleaned up");
} catch (error, stack) {
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
}
@ -222,7 +206,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
}
Future<void> _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async {
Future<void> _syncAssets({Duration? hashTimeout}) async {
final futures = <Future<void>>[];
final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async {
@ -244,10 +228,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
});
futures.add(localSyncFuture);
if (syncRemote) {
final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote();
futures.add(remoteSyncFuture);
}
futures.add(_ref.read(backgroundSyncProvider).syncRemote());
await Future.wait(futures);
}

View file

@ -1,6 +1,7 @@
import 'dart:convert';
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/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@ -35,6 +36,7 @@ class HashService {
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start();
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getAll(
@ -49,7 +51,7 @@ class HashService {
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) {
await _hashAssets(assetsToHash);
await _hashAssets(album, assetsToHash);
}
}
@ -60,7 +62,7 @@ class HashService {
/// 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
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(List<LocalAsset> assetsToHash) async {
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
int bytesProcessed = 0;
final toHash = <_AssetToPath>[];
@ -72,6 +74,9 @@ class HashService {
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
_log.warning(
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
);
continue;
}
@ -79,17 +84,17 @@ class HashService {
toHash.add(_AssetToPath(asset: asset, path: file.path));
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
await _processBatch(toHash);
await _processBatch(album, toHash);
toHash.clear();
bytesProcessed = 0;
}
}
await _processBatch(toHash);
await _processBatch(album, toHash);
}
/// Processes a batch of assets.
Future<void> _processBatch(List<_AssetToPath> toHash) async {
Future<void> _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async {
if (toHash.isEmpty) {
return;
}
@ -114,7 +119,9 @@ class HashService {
if (hash?.length == 20) {
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
} else {
_log.warning("Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt}");
_log.warning(
"Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
);
}
}

View file

@ -22,4 +22,16 @@ class LocalAlbumService {
Future<int> 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();
}
}

View file

@ -26,6 +26,10 @@ class RemoteAlbumService {
return _repository.get(albumId);
}
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
return _repository.getByName(albumName, ownerId);
}
Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, {
@ -80,7 +84,6 @@ class RemoteAlbumService {
Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async {
final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds);
await _repository.create(album, assetIds);
return album;

View file

@ -0,0 +1,101 @@
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);
}
}

View file

@ -1,5 +1,6 @@
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/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart';
@ -155,6 +156,11 @@ class BackgroundSyncManager {
_syncWebsocketTask = null;
});
}
Future<void> syncLinkedAlbum() {
final task = runInIsolateGentle(computation: syncLinkedAlbumsIsolated);
return task.future;
}
}
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(

View file

@ -0,0 +1,235 @@
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());
}
}

View file

@ -0,0 +1,11 @@
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);
}

View file

@ -1,5 +1,7 @@
import 'package:drift/drift.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';
class LocalAlbumEntity extends Table with DriftDefaultsMixin {
@ -11,9 +13,26 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin {
IntColumn get backupSelection => intEnum<BackupSelection>()();
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
BoolColumn get marker_ => boolean().nullable()();
@override
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,
);
}
}

View file

@ -7,6 +7,9 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'
as i3;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i5;
import 'package:drift/internal/modular.dart' as i6;
typedef $$LocalAlbumEntityTableCreateCompanionBuilder =
i1.LocalAlbumEntityCompanion Function({
@ -15,6 +18,7 @@ typedef $$LocalAlbumEntityTableCreateCompanionBuilder =
i0.Value<DateTime> updatedAt,
required i2.BackupSelection backupSelection,
i0.Value<bool> isIosSharedAlbum,
i0.Value<String?> linkedRemoteAlbumId,
i0.Value<bool?> marker_,
});
typedef $$LocalAlbumEntityTableUpdateCompanionBuilder =
@ -24,9 +28,57 @@ typedef $$LocalAlbumEntityTableUpdateCompanionBuilder =
i0.Value<DateTime> updatedAt,
i0.Value<i2.BackupSelection> backupSelection,
i0.Value<bool> isIosSharedAlbum,
i0.Value<String?> linkedRemoteAlbumId,
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
extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable> {
$$LocalAlbumEntityTableFilterComposer({
@ -66,6 +118,33 @@ class $$LocalAlbumEntityTableFilterComposer
column: $table.marker_,
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
@ -106,6 +185,34 @@ class $$LocalAlbumEntityTableOrderingComposer
column: $table.marker_,
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
@ -139,6 +246,34 @@ class $$LocalAlbumEntityTableAnnotationComposer
i0.GeneratedColumn<bool> get marker_ =>
$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
@ -152,16 +287,9 @@ class $$LocalAlbumEntityTableTableManager
i1.$$LocalAlbumEntityTableAnnotationComposer,
$$LocalAlbumEntityTableCreateCompanionBuilder,
$$LocalAlbumEntityTableUpdateCompanionBuilder,
(
i1.LocalAlbumEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData
>,
),
(i1.LocalAlbumEntityData, i1.$$LocalAlbumEntityTableReferences),
i1.LocalAlbumEntityData,
i0.PrefetchHooks Function()
i0.PrefetchHooks Function({bool linkedRemoteAlbumId})
> {
$$LocalAlbumEntityTableTableManager(
i0.GeneratedDatabase db,
@ -187,6 +315,7 @@ class $$LocalAlbumEntityTableTableManager
i0.Value<i2.BackupSelection> backupSelection =
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(),
}) => i1.LocalAlbumEntityCompanion(
id: id,
@ -194,6 +323,7 @@ class $$LocalAlbumEntityTableTableManager
updatedAt: updatedAt,
backupSelection: backupSelection,
isIosSharedAlbum: isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId,
marker_: marker_,
),
createCompanionCallback:
@ -203,6 +333,7 @@ class $$LocalAlbumEntityTableTableManager
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
required i2.BackupSelection backupSelection,
i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(),
i0.Value<String?> linkedRemoteAlbumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumEntityCompanion.insert(
id: id,
@ -210,12 +341,60 @@ class $$LocalAlbumEntityTableTableManager
updatedAt: updatedAt,
backupSelection: backupSelection,
isIosSharedAlbum: isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId,
marker_: marker_,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.map(
(e) => (
e.readTable(table),
i1.$$LocalAlbumEntityTableReferences(db, table, e),
),
)
.toList(),
prefetchHooksCallback: null,
prefetchHooksCallback: ({linkedRemoteAlbumId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins:
<
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic
>
>(state) {
if (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 [];
},
);
},
),
);
}
@ -230,16 +409,9 @@ typedef $$LocalAlbumEntityTableProcessedTableManager =
i1.$$LocalAlbumEntityTableAnnotationComposer,
$$LocalAlbumEntityTableCreateCompanionBuilder,
$$LocalAlbumEntityTableUpdateCompanionBuilder,
(
i1.LocalAlbumEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData
>,
),
(i1.LocalAlbumEntityData, i1.$$LocalAlbumEntityTableReferences),
i1.LocalAlbumEntityData,
i0.PrefetchHooks Function()
i0.PrefetchHooks Function({bool linkedRemoteAlbumId})
>;
class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
@ -308,6 +480,20 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
),
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(
'marker_',
);
@ -329,6 +515,7 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
updatedAt,
backupSelection,
isIosSharedAlbum,
linkedRemoteAlbumId,
marker_,
];
@override
@ -371,6 +558,15 @@ 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')) {
context.handle(
_marker_Meta,
@ -412,6 +608,10 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
i0.DriftSqlType.bool,
data['${effectivePrefix}is_ios_shared_album'],
)!,
linkedRemoteAlbumId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}linked_remote_album_id'],
),
marker_: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}marker'],
@ -441,6 +641,7 @@ class LocalAlbumEntityData extends i0.DataClass
final DateTime updatedAt;
final i2.BackupSelection backupSelection;
final bool isIosSharedAlbum;
final String? linkedRemoteAlbumId;
final bool? marker_;
const LocalAlbumEntityData({
required this.id,
@ -448,6 +649,7 @@ class LocalAlbumEntityData extends i0.DataClass
required this.updatedAt,
required this.backupSelection,
required this.isIosSharedAlbum,
this.linkedRemoteAlbumId,
this.marker_,
});
@override
@ -464,6 +666,9 @@ class LocalAlbumEntityData extends i0.DataClass
);
}
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) {
map['marker'] = i0.Variable<bool>(marker_);
}
@ -482,6 +687,9 @@ class LocalAlbumEntityData extends i0.DataClass
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
.fromJson(serializer.fromJson<int>(json['backupSelection'])),
isIosSharedAlbum: serializer.fromJson<bool>(json['isIosSharedAlbum']),
linkedRemoteAlbumId: serializer.fromJson<String?>(
json['linkedRemoteAlbumId'],
),
marker_: serializer.fromJson<bool?>(json['marker_']),
);
}
@ -498,6 +706,7 @@ class LocalAlbumEntityData extends i0.DataClass
),
),
'isIosSharedAlbum': serializer.toJson<bool>(isIosSharedAlbum),
'linkedRemoteAlbumId': serializer.toJson<String?>(linkedRemoteAlbumId),
'marker_': serializer.toJson<bool?>(marker_),
};
}
@ -508,6 +717,7 @@ class LocalAlbumEntityData extends i0.DataClass
DateTime? updatedAt,
i2.BackupSelection? backupSelection,
bool? isIosSharedAlbum,
i0.Value<String?> linkedRemoteAlbumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumEntityData(
id: id ?? this.id,
@ -515,6 +725,9 @@ class LocalAlbumEntityData extends i0.DataClass
updatedAt: updatedAt ?? this.updatedAt,
backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId.present
? linkedRemoteAlbumId.value
: this.linkedRemoteAlbumId,
marker_: marker_.present ? marker_.value : this.marker_,
);
LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) {
@ -528,6 +741,9 @@ class LocalAlbumEntityData extends i0.DataClass
isIosSharedAlbum: data.isIosSharedAlbum.present
? data.isIosSharedAlbum.value
: this.isIosSharedAlbum,
linkedRemoteAlbumId: data.linkedRemoteAlbumId.present
? data.linkedRemoteAlbumId.value
: this.linkedRemoteAlbumId,
marker_: data.marker_.present ? data.marker_.value : this.marker_,
);
}
@ -540,6 +756,7 @@ class LocalAlbumEntityData extends i0.DataClass
..write('updatedAt: $updatedAt, ')
..write('backupSelection: $backupSelection, ')
..write('isIosSharedAlbum: $isIosSharedAlbum, ')
..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ')
..write('marker_: $marker_')
..write(')'))
.toString();
@ -552,6 +769,7 @@ class LocalAlbumEntityData extends i0.DataClass
updatedAt,
backupSelection,
isIosSharedAlbum,
linkedRemoteAlbumId,
marker_,
);
@override
@ -563,6 +781,7 @@ class LocalAlbumEntityData extends i0.DataClass
other.updatedAt == this.updatedAt &&
other.backupSelection == this.backupSelection &&
other.isIosSharedAlbum == this.isIosSharedAlbum &&
other.linkedRemoteAlbumId == this.linkedRemoteAlbumId &&
other.marker_ == this.marker_);
}
@ -573,6 +792,7 @@ class LocalAlbumEntityCompanion
final i0.Value<DateTime> updatedAt;
final i0.Value<i2.BackupSelection> backupSelection;
final i0.Value<bool> isIosSharedAlbum;
final i0.Value<String?> linkedRemoteAlbumId;
final i0.Value<bool?> marker_;
const LocalAlbumEntityCompanion({
this.id = const i0.Value.absent(),
@ -580,6 +800,7 @@ class LocalAlbumEntityCompanion
this.updatedAt = const i0.Value.absent(),
this.backupSelection = const i0.Value.absent(),
this.isIosSharedAlbum = const i0.Value.absent(),
this.linkedRemoteAlbumId = const i0.Value.absent(),
this.marker_ = const i0.Value.absent(),
});
LocalAlbumEntityCompanion.insert({
@ -588,6 +809,7 @@ class LocalAlbumEntityCompanion
this.updatedAt = const i0.Value.absent(),
required i2.BackupSelection backupSelection,
this.isIosSharedAlbum = const i0.Value.absent(),
this.linkedRemoteAlbumId = const i0.Value.absent(),
this.marker_ = const i0.Value.absent(),
}) : id = i0.Value(id),
name = i0.Value(name),
@ -598,6 +820,7 @@ class LocalAlbumEntityCompanion
i0.Expression<DateTime>? updatedAt,
i0.Expression<int>? backupSelection,
i0.Expression<bool>? isIosSharedAlbum,
i0.Expression<String>? linkedRemoteAlbumId,
i0.Expression<bool>? marker_,
}) {
return i0.RawValuesInsertable({
@ -606,6 +829,8 @@ class LocalAlbumEntityCompanion
if (updatedAt != null) 'updated_at': updatedAt,
if (backupSelection != null) 'backup_selection': backupSelection,
if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum,
if (linkedRemoteAlbumId != null)
'linked_remote_album_id': linkedRemoteAlbumId,
if (marker_ != null) 'marker': marker_,
});
}
@ -616,6 +841,7 @@ class LocalAlbumEntityCompanion
i0.Value<DateTime>? updatedAt,
i0.Value<i2.BackupSelection>? backupSelection,
i0.Value<bool>? isIosSharedAlbum,
i0.Value<String?>? linkedRemoteAlbumId,
i0.Value<bool?>? marker_,
}) {
return i1.LocalAlbumEntityCompanion(
@ -624,6 +850,7 @@ class LocalAlbumEntityCompanion
updatedAt: updatedAt ?? this.updatedAt,
backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
marker_: marker_ ?? this.marker_,
);
}
@ -650,6 +877,11 @@ class LocalAlbumEntityCompanion
if (isIosSharedAlbum.present) {
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) {
map['marker'] = i0.Variable<bool>(marker_.value);
}
@ -664,6 +896,7 @@ class LocalAlbumEntityCompanion
..write('updatedAt: $updatedAt, ')
..write('backupSelection: $backupSelection, ')
..write('isIosSharedAlbum: $isIosSharedAlbum, ')
..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ')
..write('marker_: $marker_')
..write(')'))
.toString();

View file

@ -20,7 +20,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
Set<Column> get primaryKey => {id};
}
extension LocalAssetEntityDataDomainEx on LocalAssetEntityData {
extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
LocalAsset toDto() => LocalAsset(
id: id,
name: name,

View file

@ -4,9 +4,10 @@ import 'package:drift/drift.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/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/providers/infrastructure/db.provider.dart';
import "package:immich_mobile/utils/database.utils.dart";
final backupRepositoryProvider = Provider<DriftBackupRepository>(
(ref) => DriftBackupRepository(ref.watch(driftProvider)),

View file

@ -68,7 +68,7 @@ class Drift extends $Drift implements IDatabaseRepository {
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
@override
int get schemaVersion => 8;
int get schemaVersion => 9;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -123,6 +123,9 @@ class Drift extends $Drift implements IDatabaseRepository {
from7To8: (m, v8) async {
await m.create(v8.storeEntity);
},
from8To9: (m, v9) async {
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
},
),
);

View file

@ -9,17 +9,17 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i11;
@ -48,19 +48,19 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i3.$StackEntityTable stackEntity = i3.$StackEntityTable(this);
late final i4.$LocalAssetEntityTable localAssetEntity = i4
.$LocalAssetEntityTable(this);
late final i5.$LocalAlbumEntityTable localAlbumEntity = i5
late final i5.$RemoteAlbumEntityTable remoteAlbumEntity = i5
.$RemoteAlbumEntityTable(this);
late final i6.$LocalAlbumEntityTable localAlbumEntity = i6
.$LocalAlbumEntityTable(this);
late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i6
late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i7
.$LocalAlbumAssetEntityTable(this);
late final i7.$UserMetadataEntityTable userMetadataEntity = i7
late final i8.$UserMetadataEntityTable userMetadataEntity = i8
.$UserMetadataEntityTable(this);
late final i8.$PartnerEntityTable partnerEntity = i8.$PartnerEntityTable(
late final i9.$PartnerEntityTable partnerEntity = i9.$PartnerEntityTable(
this,
);
late final i9.$RemoteExifEntityTable remoteExifEntity = i9
late final i10.$RemoteExifEntityTable remoteExifEntity = i10
.$RemoteExifEntityTable(this);
late final i10.$RemoteAlbumEntityTable remoteAlbumEntity = i10
.$RemoteAlbumEntityTable(this);
late final i11.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = i11
.$RemoteAlbumAssetEntityTable(this);
late final i12.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i12
@ -84,6 +84,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
i4.idxLocalAssetChecksum,
@ -94,7 +95,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
@ -102,7 +102,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
personEntity,
assetFaceEntity,
storeEntity,
i9.idxLatLng,
i10.idxLatLng,
];
@override
i0.StreamQueryUpdateRules
@ -123,6 +123,33 @@ abstract class $Drift extends i0.GeneratedDatabase {
),
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(
on: i0.TableUpdateQuery.onTableName(
'local_asset_entity',
@ -173,24 +200,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
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(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
@ -290,18 +299,18 @@ class $DriftManager {
i3.$$StackEntityTableTableManager(_db, _db.stackEntity);
i4.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i4.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i5.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i5.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6
i5.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i5.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i7.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i7.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i8.$$PartnerEntityTableTableManager get partnerEntity =>
i8.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i9.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i9.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i10.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i10.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i8.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i8.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i9.$$PartnerEntityTableTableManager get partnerEntity =>
i9.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i10.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i10.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i11.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i11.$$RemoteAlbumAssetEntityTableTableManager(
_db,

View file

@ -3435,6 +3435,391 @@ i1.GeneratedColumn<int> _column_89(String aliasedName) =>
true,
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({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -3443,6 +3828,7 @@ i0.MigrationStepWithVersion migrationSteps({
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, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -3481,6 +3867,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from7To8(migrator, schema);
return 8;
case 8:
final schema = Schema9(database: database);
final migrator = i1.Migrator(database, schema);
await from8To9(migrator, schema);
return 9;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -3495,6 +3886,7 @@ i1.OnUpgrade stepByStep({
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, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -3504,5 +3896,6 @@ i1.OnUpgrade stepByStep({
from5To6: from5To6,
from6To7: from6To7,
from7To8: from7To8,
from8To9: from8To9,
),
);

View file

@ -1,11 +1,12 @@
import 'package:drift/drift.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/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_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/repositories/db.repository.dart';
import 'package:immich_mobile/utils/database.utils.dart';
import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
@ -49,6 +50,13 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
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 {
// 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
@ -335,4 +343,16 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
Future<int> getCount() {
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)));
}
}

View file

@ -113,6 +113,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.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 {
await _db.transaction(() async {
final entity = RemoteAlbumEntityCompanion(
@ -321,6 +330,42 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
Future<int> getCount() {
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 {

View file

@ -16,7 +16,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.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/backup/backup.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
@ -26,7 +25,6 @@ import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/routing/app_navigation_observer.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/deep_link.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
@ -207,12 +205,9 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
// needs to be delayed so that EasyLocalization is working
if (Store.isBetaTimelineEnabled) {
ref.read(backgroundServiceProvider).disableService();
ref.read(driftBackgroundUploadFgService).enableSyncService();
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
ref.read(driftBackgroundUploadFgService).enableUploadService();
}
ref.read(driftBackgroundUploadFgService).enable();
} else {
ref.read(driftBackgroundUploadFgService).disableUploadService();
ref.read(driftBackgroundUploadFgService).disable();
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
}
});

View file

@ -8,7 +8,6 @@ import 'package:immich_mobile/extensions/theme_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/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/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -43,12 +42,10 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
await ref.read(backgroundSyncProvider).syncRemote();
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(driftBackgroundUploadFgService).enableUploadService();
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
}
Future<void> stopBackup() async {
await ref.read(driftBackgroundUploadFgService).disableUploadService();
await ref.read(driftBackupProvider.notifier).cancel();
}

View file

@ -7,6 +7,7 @@ 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/translate_extensions.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/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -26,10 +27,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
String _searchQuery = '';
bool _isSearchMode = false;
int _initialTotalAssetCount = 0;
bool _hasPopped = false;
late ValueNotifier<bool> _enableSyncUploadAlbum;
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
Future? _handleLinkedAlbumFuture;
@override
void initState() {
@ -44,6 +45,36 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
_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
void dispose() {
_enableSyncUploadAlbum.dispose();
@ -65,42 +96,12 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).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(
onPopInvokedWithResult: (didPop, result) async {
// There is an issue with Flutter where the pop event
// can be triggered multiple times, so we guard it with _hasPopped
if (didPop && !_hasPopped) {
_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);
});
}
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (!didPop) {
await _handlePagePopped();
Navigator.of(context).pop();
}
},
child: Scaffold(
@ -139,103 +140,123 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
],
elevation: 0,
),
body: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text(
"backup_album_selection_page_selection_info",
style: context.textTheme.titleSmall,
).t(context: context),
),
body: Stack(
children: [
CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text(
"backup_album_selection_page_selection_info",
style: context.textTheme.titleSmall,
).t(context: context),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
_SelectedAlbumNameChips(selectedBackupAlbums: selectedBackupAlbums),
_ExcludedAlbumNameChips(excludedBackupAlbums: excludedBackupAlbums),
],
),
),
// SettingsSwitchListTile(
// valueNotifier: _enableSyncUploadAlbum,
// title: "sync_albums".t(context: context),
// subtitle: "sync_upload_album_setting_subtitle".t(context: context),
// contentPadding: const EdgeInsets.symmetric(horizontal: 16),
// titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
// subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
// onChanged: handleSyncAlbumToggle,
// ),
ListTile(
title: Text(
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
).t(context: context),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(Icons.info, size: 20, color: context.primaryColor),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
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),
],
),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
_SelectedAlbumNameChips(selectedBackupAlbums: selectedBackupAlbums),
_ExcludedAlbumNameChips(excludedBackupAlbums: excludedBackupAlbums),
],
),
),
ListTile(
title: Text(
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
).t(context: context),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(Icons.info, size: 20, color: context.primaryColor),
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
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),
],
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),
],
),
),
),
);
},
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
} else {
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
}
},
),
],
),
),

View file

@ -79,7 +79,7 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
ref.read(readonlyModeProvider.notifier).setReadonlyMode(false);
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
await ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
await ref.read(driftBackgroundUploadFgService).disableUploadService();
await ref.read(driftBackgroundUploadFgService).disable();
}
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);

View file

@ -2,8 +2,10 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@ -21,14 +23,23 @@ class SplashScreenPage extends StatefulHookConsumerWidget {
class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final log = Logger("SplashScreenPage");
@override
void initState() {
super.initState();
ref
.read(authProvider.notifier)
.setOpenApiServiceEndpoint()
.then(logConnectionInfo)
.whenComplete(() => resumeSession());
final lockManager = ref.read(isolateLockManagerProvider(kIsolateLockManagerPort));
lockManager.requestHolderToClose();
lockManager
.acquireLock()
.timeout(const Duration(seconds: 5))
.whenComplete(
() => ref
.read(authProvider.notifier)
.setOpenApiServiceEndpoint()
.then(logConnectionInfo)
.whenComplete(() => resumeSession()),
);
}
void logConnectionInfo(String? endpoint) {

View file

@ -59,9 +59,9 @@ class BackgroundWorkerFgHostApi {
final String pigeonVar_messageChannelSuffix;
Future<void> enableSyncWorker() async {
Future<void> enable() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$pigeonVar_messageChannelSuffix';
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@ -82,32 +82,9 @@ class BackgroundWorkerFgHostApi {
}
}
Future<void> enableUploadWorker() async {
Future<void> disable() async {
final String pigeonVar_channelName =
'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(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;
}
}
Future<void> disableUploadWorker() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$pigeonVar_messageChannelSuffix';
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@ -192,8 +169,6 @@ class BackgroundWorkerBgHostApi {
abstract class BackgroundWorkerFlutterApi {
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
Future<void> onLocalSync(int? maxSeconds);
Future<void> onIosUpload(bool isRefresh, int? maxSeconds);
Future<void> onAndroidUpload();
@ -206,35 +181,6 @@ abstract class BackgroundWorkerFlutterApi {
String 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?>(
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix',

View file

@ -1,5 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@ -38,14 +39,14 @@ class DriftPlacePage extends StatelessWidget {
}
}
class _PlaceSliverAppBar extends StatelessWidget {
class _PlaceSliverAppBar extends HookWidget {
const _PlaceSliverAppBar({required this.search});
final ValueNotifier<String?> search;
@override
Widget build(BuildContext context) {
final searchFocusNode = FocusNode();
final searchFocusNode = useFocusNode();
return SliverAppBar(
floating: true,

View file

@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.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/immich_toast.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@ -39,8 +40,12 @@ class AlbumSelector extends ConsumerStatefulWidget {
class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
bool isGrid = false;
final searchController = TextEditingController();
QuickFilterMode filterMode = QuickFilterMode.all;
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
void initState() {
@ -52,7 +57,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
});
searchController.addListener(() {
onSearch(searchController.text, filterMode);
onSearch(searchController.text, filter.mode);
});
searchFocusNode.addListener(() {
@ -62,9 +67,11 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
});
}
void onSearch(String searchTerm, QuickFilterMode sortMode) {
void onSearch(String searchTerm, QuickFilterMode filterMode) {
final userId = ref.watch(currentUserProvider)?.id;
ref.read(remoteAlbumProvider.notifier).searchAlbums(searchTerm, userId, sortMode);
filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode);
filterAlbums();
}
Future<void> onRefresh() async {
@ -77,17 +84,60 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
});
}
void changeFilter(QuickFilterMode sortMode) {
void changeFilter(QuickFilterMode mode) {
setState(() {
filterMode = sortMode;
filter = filter.copyWith(mode: mode);
});
filterAlbums();
}
Future<void> changeSort(AlbumSort sort) async {
setState(() {
this.sort = sort;
});
await sortAlbums();
}
void clearSearch() {
setState(() {
filterMode = QuickFilterMode.all;
filter = filter.copyWith(mode: QuickFilterMode.all, query: null);
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;
});
}
@ -100,36 +150,41 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
@override
Widget build(BuildContext context) {
final albums = ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums));
final userId = ref.watch(currentUserProvider)?.id;
// refilter and sort when albums change
ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async {
await sortAlbums();
});
return MultiSliver(
children: [
_SearchBar(
searchController: searchController,
searchFocusNode: searchFocusNode,
onSearch: onSearch,
filterMode: filterMode,
filterMode: filter.mode,
onClearSearch: clearSearch,
),
_QuickFilterButtonRow(
filterMode: filterMode,
filterMode: filter.mode,
onChangeFilter: changeFilter,
onSearch: onSearch,
searchController: searchController,
),
_QuickSortAndViewMode(isGrid: isGrid, onToggleViewMode: toggleViewMode),
_QuickSortAndViewMode(isGrid: isGrid, onToggleViewMode: toggleViewMode, onSortChanged: changeSort),
isGrid
? _AlbumGrid(albums: albums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
: _AlbumList(albums: albums, userId: userId, onAlbumSelected: widget.onAlbumSelected),
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
: _AlbumList(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected),
],
);
}
}
class _SortButton extends ConsumerStatefulWidget {
const _SortButton();
const _SortButton(this.onSortChanged);
final Future<void> Function(AlbumSort) onSortChanged;
@override
ConsumerState<_SortButton> createState() => _SortButtonState();
@ -148,15 +203,15 @@ class _SortButtonState extends ConsumerState<_SortButton> {
albumSortIsReverse = !albumSortIsReverse;
isSorting = true;
});
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
} else {
setState(() {
albumSortOption = sortMode;
isSorting = true;
});
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
}
await widget.onSortChanged.call(AlbumSort(mode: albumSortOption, isReverse: albumSortIsReverse));
setState(() {
isSorting = false;
});
@ -394,10 +449,11 @@ class _QuickFilterButton extends StatelessWidget {
}
class _QuickSortAndViewMode extends StatelessWidget {
const _QuickSortAndViewMode({required this.isGrid, required this.onToggleViewMode});
const _QuickSortAndViewMode({required this.isGrid, required this.onToggleViewMode, required this.onSortChanged});
final bool isGrid;
final VoidCallback onToggleViewMode;
final Future<void> Function(AlbumSort) onSortChanged;
@override
Widget build(BuildContext context) {
@ -407,7 +463,7 @@ class _QuickSortAndViewMode extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const _SortButton(),
_SortButton(onSortChanged),
IconButton(
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: onToggleViewMode,

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/timeline.model.dart';
@ -129,6 +130,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
reloadSubscription?.cancel();
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@ -596,6 +598,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Rebuild the widget when the asset viewer 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.showingControls));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
ref.watch(isPlayingMotionVideoProvider);
@ -612,6 +615,15 @@ 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.
// Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable

View file

@ -62,7 +62,7 @@ class ViewerBottomBar extends ConsumerWidget {
duration: Durations.short2,
child: AnimatedSwitcher(
duration: Durations.short4,
child: isSheetOpen || isReadonlyModeEnabled
child: isSheetOpen
? const SizedBox.shrink()
: Theme(
data: context.themeData.copyWith(
@ -72,14 +72,14 @@ class ViewerBottomBar extends ConsumerWidget {
),
),
child: Container(
height: context.padding.bottom + (asset.isVideo ? 160 : 90),
color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom),
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (asset.isVideo) const VideoControls(),
if (!isInLockedView) Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
if (!isInLockedView && !isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),

View file

@ -1,7 +1,9 @@
import 'package:flutter/material.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/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/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@ -16,22 +18,74 @@ 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/trash_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/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoteAlbumBottomSheet extends ConsumerWidget {
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
final RemoteAlbum album;
const RemoteAlbumBottomSheet({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<RemoteAlbumBottomSheet> createState() => _RemoteAlbumBottomSheetState();
}
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 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(
initialChildSize: 0.25,
maxChildSize: 0.4,
controller: sheetController,
initialChildSize: 0.45,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
@ -52,7 +106,11 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(source: ActionSource.timeline),
],
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: album.id),
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
);
}

View file

@ -47,10 +47,12 @@ class _DriftMapState extends ConsumerState<DriftMap> {
MapLibreMapController? mapController;
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
@override
void dispose() {
_debouncer.dispose();
bottomSheetOffset.dispose();
super.dispose();
}
@ -157,8 +159,8 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack(
children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_MyLocationButton(onZoomToLocation: onZoomToLocation),
const MapBottomSheet(),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
],
);
}
@ -191,21 +193,53 @@ class _Map extends StatelessWidget {
}
}
class _MyLocationButton extends StatelessWidget {
const _MyLocationButton({required this.onZoomToLocation});
class _DynamicBottomSheet extends StatefulWidget {
final ValueNotifier<double> bottomSheetOffset;
final VoidCallback onZoomToLocation;
const _DynamicBottomSheet({required this.bottomSheetOffset});
@override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
}
class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
@override
Widget build(BuildContext context) {
return Positioned(
right: 0,
bottom: context.padding.bottom + 16,
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.my_location),
),
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 ValueNotifier<double> bottomSheetOffset;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<double>(
valueListenable: bottomSheetOffset,
builder: (context, offset, child) {
return Positioned(
right: 16,
bottom: context.height * (offset - 0.02) + context.padding.bottom,
child: AnimatedOpacity(
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),
),
),
);
},
);
}
}

View file

@ -10,6 +10,7 @@ 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/timeline.state.dart';
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
/// for quick navigation of the BoxScrollView.
@ -74,6 +75,7 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
}
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
String? _lastLabel;
double _thumbTopOffset = 0.0;
bool _isDragging = false;
List<_Segment> _segments = [];
@ -172,6 +174,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_isDragging = true;
_labelAnimationController.forward();
_fadeOutTimer?.cancel();
_lastLabel = null;
});
}
@ -189,6 +192,11 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
if (nearestMonthSegment != null) {
_snapToSegment(nearestMonthSegment);
final label = nearestMonthSegment.scrollLabel;
if (_lastLabel != label) {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
_lastLabel = label;
}
}
}

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
@ -81,6 +82,12 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
} else {
_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);
// Ensure proper cleanup before starting new background tasks
@ -98,6 +105,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
]).then((_) async {
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
if (isEnableBackup) {
final currentUser = _ref.read(currentUserProvider);
if (currentUser == null) {
@ -106,6 +115,10 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
}
if (isAlbumLinkedSyncEnable) {
await backgroundManager.syncLinkedAlbum();
}
});
} catch (e, stackTrace) {
Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace);
@ -130,7 +143,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
// do not stop/clean up anything on inactivity: issued on every orientation change
}
void handleAppPause() {
Future<void> handleAppPause() async {
state = AppLifeCycleEnum.paused;
_wasPaused = true;
@ -140,6 +153,12 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) {
_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();
@ -173,6 +192,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
if (Store.isBetaTimelineEnabled) {
_ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock();
return;
}

View file

@ -1,5 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.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';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
@ -18,3 +19,7 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
ref.onDispose(manager.cancel);
return manager;
});
final isolateLockManagerProvider = Provider.family<IsolateLockManager, String>((ref, name) {
return IsolateLockManager(portName: name);
});

View file

@ -12,43 +12,42 @@ import 'album.provider.dart';
class RemoteAlbumState {
final List<RemoteAlbum> albums;
final List<RemoteAlbum> filteredAlbums;
const RemoteAlbumState({required this.albums, List<RemoteAlbum>? filteredAlbums})
: filteredAlbums = filteredAlbums ?? albums;
const RemoteAlbumState({required this.albums});
RemoteAlbumState copyWith({List<RemoteAlbum>? albums, List<RemoteAlbum>? filteredAlbums}) {
return RemoteAlbumState(albums: albums ?? this.albums, filteredAlbums: filteredAlbums ?? this.filteredAlbums);
RemoteAlbumState copyWith({List<RemoteAlbum>? albums}) {
return RemoteAlbumState(albums: albums ?? this.albums);
}
@override
String toString() => 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length})';
String toString() => 'RemoteAlbumState(albums: ${albums.length})';
@override
bool operator ==(covariant RemoteAlbumState other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.albums, albums) && listEquals(other.filteredAlbums, filteredAlbums);
return listEquals(other.albums, albums);
}
@override
int get hashCode => albums.hashCode ^ filteredAlbums.hashCode;
int get hashCode => albums.hashCode;
}
class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
late RemoteAlbumService _remoteAlbumService;
final _logger = Logger('RemoteAlbumNotifier');
@override
RemoteAlbumState build() {
_remoteAlbumService = ref.read(remoteAlbumServiceProvider);
return const RemoteAlbumState(albums: [], filteredAlbums: []);
return const RemoteAlbumState(albums: []);
}
Future<List<RemoteAlbum>> _getAll() async {
try {
final albums = await _remoteAlbumService.getAll();
state = state.copyWith(albums: albums, filteredAlbums: albums);
state = state.copyWith(albums: albums);
return albums;
} catch (error, stack) {
_logger.severe('Failed to fetch albums', error, stack);
@ -60,19 +59,21 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
await _getAll();
}
void searchAlbums(String query, String? userId, [QuickFilterMode filterMode = QuickFilterMode.all]) {
final filtered = _remoteAlbumService.searchAlbums(state.albums, query, userId, filterMode);
state = state.copyWith(filteredAlbums: filtered);
List<RemoteAlbum> searchAlbums(
List<RemoteAlbum> albums,
String query,
String? userId, [
QuickFilterMode filterMode = QuickFilterMode.all,
]) {
return _remoteAlbumService.searchAlbums(albums, query, userId, filterMode);
}
void clearSearch() {
state = state.copyWith(filteredAlbums: state.albums);
}
Future<void> sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) async {
final sortedAlbums = await _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
state = state.copyWith(filteredAlbums: sortedAlbums);
Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, {
bool isReverse = false,
}) async {
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);
}
Future<RemoteAlbum?> createAlbum({
@ -83,7 +84,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
try {
final album = await _remoteAlbumService.createAlbum(title: title, description: description, assetIds: assetIds);
state = state.copyWith(albums: [...state.albums, album], filteredAlbums: [...state.filteredAlbums, album]);
state = state.copyWith(albums: [...state.albums, album]);
return album;
} catch (error, stack) {
@ -114,11 +115,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return album.id == albumId ? updatedAlbum : album;
}).toList();
final updatedFilteredAlbums = state.filteredAlbums.map((album) {
return album.id == albumId ? updatedAlbum : album;
}).toList();
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
state = state.copyWith(albums: updatedAlbums);
return updatedAlbum;
} catch (error, stack) {
@ -139,9 +136,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
await _remoteAlbumService.deleteAlbum(albumId);
final updatedAlbums = state.albums.where((album) => album.id != albumId).toList();
final updatedFilteredAlbums = state.filteredAlbums.where((album) => album.id != albumId).toList();
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
state = state.copyWith(albums: updatedAlbums);
}
Future<List<RemoteAsset>> getAssets(String albumId) {
@ -164,9 +159,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
await _remoteAlbumService.removeUser(albumId, userId: userId);
final updatedAlbums = state.albums.where((album) => album.id != albumId).toList();
final updatedFilteredAlbums = state.filteredAlbums.where((album) => album.id != albumId).toList();
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
state = state.copyWith(albums: updatedAlbums);
}
Future<void> setActivityStatus(String albumId, bool enabled) {

View file

@ -12,7 +12,6 @@ import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.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/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
@ -323,7 +322,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}
try {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()));
unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) {
return _ref.read(backgroundSyncProvider).syncLinkedAlbum();
}),
);
} catch (error) {
_log.severe("Error processing batched AssetUploadReadyV1 events: $error");
}

View file

@ -282,6 +282,8 @@ class UploadService {
return buildUploadTask(
file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
@ -309,6 +311,8 @@ class UploadService {
return buildUploadTask(
file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: asset.name,
deviceAssetId: asset.id,
fields: fields,
@ -334,6 +338,8 @@ class UploadService {
Future<UploadTask> buildUploadTask(
File file, {
required String group,
required DateTime createdAt,
required DateTime modifiedAt,
Map<String, String>? fields,
String? originalFileName,
String? deviceAssetId,
@ -347,15 +353,12 @@ class UploadService {
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
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 = {
'filename': originalFileName ?? filename,
'deviceAssetId': deviceAssetId ?? '',
'deviceId': deviceId,
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'fileCreatedAt': createdAt.toUtc().toIso8601String(),
'fileModifiedAt': modifiedAt.toUtc().toIso8601String(),
'isFavorite': isFavorite?.toString() ?? 'false',
'duration': '0',
if (fields != null) ...fields,

View file

@ -0,0 +1,25 @@
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);
}
}

View file

@ -1,31 +0,0 @@
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,
);
}
}

View file

@ -23,8 +23,10 @@ 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/user.entity.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/backup/backup.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
@ -268,11 +270,17 @@ Future<List<void>> runNewSync(WidgetRef ref, {bool full = false}) {
ref.read(backupProvider.notifier).cancelBackup();
final backgroundManager = ref.read(backgroundSyncProvider);
final isAlbumLinkedSyncEnable = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
return Future.wait([
backgroundManager.syncLocal(full: full).then((_) {
Logger("runNewSync").fine("Hashing assets after syncLocal");
return backgroundManager.hashAssets();
}),
backgroundManager.syncRemote(),
backgroundManager.syncRemote().then((_) {
if (isAlbumLinkedSyncEnable) {
return backgroundManager.syncLinkedAlbum();
}
}),
]);
}

View file

@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.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_bottom_sheet.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/upload_dialog.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/widgets/common/drag_sheet.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';
final controlBottomAppBarNotifier = ControlBottomAppBarNotifier();
@ -45,6 +47,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
final bool unfavorite;
final bool unarchive;
final AssetSelectionState selectionAssetState;
final List<Asset> selectedAssets;
const ControlBottomAppBar({
super.key,
@ -64,6 +67,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
this.onRemoveFromAlbum,
this.onToggleLocked,
this.selectionAssetState = const AssetSelectionState(),
this.selectedAssets = const [],
this.enabled = true,
this.unarchive = false,
this.unfavorite = false,
@ -100,6 +104,18 @@ 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}) {
if (!force) {
deleteCb(force);
@ -121,6 +137,15 @@ class ControlBottomAppBar extends HookConsumerWidget {
label: "share_link".tr(),
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)
ControlBoxButton(
iconData: unarchive ? Icons.unarchive_outlined : Icons.archive_outlined,

View file

@ -440,6 +440,7 @@ class MultiselectGrid extends HookConsumerWidget {
onUpload: onUpload,
enabled: !processing.value,
selectionAssetState: selectionAssetState.value,
selectedAssets: selection.value.toList(),
onStack: stackEnabled ? onStack : null,
onEditTime: editEnabled ? onEditTime : null,
onEditLocation: editEnabled ? onEditLocation : null,

View file

@ -4,12 +4,9 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/haptic_feedback.provider.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';
class DriftAlbumInfoListTile extends HookConsumerWidget {
@ -22,8 +19,6 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
final bool isSelected = album.backupSelection == BackupSelection.selected;
final bool isExcluded = album.backupSelection == BackupSelection.excluded;
final syncAlbum = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
buildTileColor() {
if (isSelected) {
return context.isDarkTheme ? context.primaryColor.withAlpha(100) : context.primaryColor.withAlpha(25);
@ -75,9 +70,6 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
} else {
ref.read(backupAlbumProvider.notifier).selectAlbum(album);
if (syncAlbum) {
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
},
leading: buildIcon(),

View file

@ -1,7 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
@ -259,7 +260,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
const AppBarProfileInfoBox(),
buildStorageInformation(),
const AppBarServerInfo(),
if (isReadonlyModeEnabled) buildReadonlyMessage(),
if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildSettingButton(),
buildSignOutButton(),

View file

@ -121,7 +121,6 @@ class PhotoViewCore extends StatefulWidget {
class PhotoViewCoreState extends State<PhotoViewCore>
with TickerProviderStateMixin, PhotoViewControllerDelegate, HitCornersDetector {
Offset? _normalizedPosition;
double? _scaleBefore;
double? _rotationBefore;
@ -154,7 +153,6 @@ class PhotoViewCoreState extends State<PhotoViewCore>
void onScaleStart(ScaleStartDetails details) {
_rotationBefore = controller.rotation;
_scaleBefore = scale;
_normalizedPosition = details.focalPoint - controller.position;
_scaleAnimationController.stop();
_positionAnimationController.stop();
_rotationAnimationController.stop();
@ -166,8 +164,14 @@ class PhotoViewCoreState extends State<PhotoViewCore>
};
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;
Offset delta = details.focalPoint - _normalizedPosition!;
final double scaleDelta = newScale / scale;
final Offset newPosition =
(controller.position + details.focalPointDelta) * scaleDelta - centeredFocalPoint * (scaleDelta - 1);
updateScaleStateFromNewScale(newScale);
@ -176,7 +180,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
updateMultiple(
scale: newScale,
position: panEnabled ? delta : clampPosition(position: delta * details.scale),
position: panEnabled ? newPosition : clampPosition(position: newPosition),
rotation: rotationEnabled ? _rotationBefore! + details.rotation : null,
rotationFocusPoint: rotationEnabled ? details.focalPoint : null,
);

View file

@ -1,19 +1,153 @@
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/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.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/widgets/settings/settings_sub_page_scaffold.dart';
class DriftBackupSettings extends StatelessWidget {
class DriftBackupSettings extends ConsumerWidget {
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
Widget build(BuildContext context) {
return const SettingsSubPageScaffold(settings: [_UseWifiForUploadVideosButton(), _UseWifiForUploadPhotosButton()]);
return ListView(
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(),
),
),
],
);
},
),
],
);
}
}

View file

@ -109,6 +109,37 @@ class BetaSyncSettings extends HookConsumerWidget {
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>>(
future: loadCounts(),
builder: (context, snapshot) {
@ -116,6 +147,33 @@ class BetaSyncSettings extends HookConsumerWidget {
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 localAssetCount = assetCounts.$1;
final remoteAssetCount = assetCounts.$2;
@ -270,34 +328,7 @@ class BetaSyncSettings extends HookConsumerWidget {
),
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
onTap: () async {
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),
),
),
],
);
},
);
await resetSqliteDb(context, resetDatabase);
},
),
],

View file

@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.140.1
- API version: 1.141.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View file

@ -31,7 +31,8 @@ class SmartSearchDto {
this.model,
this.page,
this.personIds = const [],
required this.query,
this.query,
this.queryAssetId,
this.rating,
this.size,
this.state,
@ -151,7 +152,21 @@ class SmartSearchDto {
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
/// Maximum value: 5
@ -278,6 +293,7 @@ class SmartSearchDto {
other.page == page &&
_deepEquality.equals(other.personIds, personIds) &&
other.query == query &&
other.queryAssetId == queryAssetId &&
other.rating == rating &&
other.size == size &&
other.state == state &&
@ -314,7 +330,8 @@ class SmartSearchDto {
(model == null ? 0 : model!.hashCode) +
(page == null ? 0 : page!.hashCode) +
(personIds.hashCode) +
(query.hashCode) +
(query == null ? 0 : query!.hashCode) +
(queryAssetId == null ? 0 : queryAssetId!.hashCode) +
(rating == null ? 0 : rating!.hashCode) +
(size == null ? 0 : size!.hashCode) +
(state == null ? 0 : state!.hashCode) +
@ -331,7 +348,7 @@ class SmartSearchDto {
(withExif == null ? 0 : withExif!.hashCode);
@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, 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, 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]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -417,7 +434,16 @@ class SmartSearchDto {
// json[r'page'] = null;
}
json[r'personIds'] = this.personIds;
if (this.query != null) {
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) {
json[r'rating'] = this.rating;
} else {
@ -522,7 +548,8 @@ class SmartSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: 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']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
@ -586,7 +613,6 @@ class SmartSearchDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'query',
};
}

View file

@ -69,6 +69,7 @@ class SyncEntityType {
static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1');
static const syncAckV1 = SyncEntityType._(r'SyncAckV1');
static const syncResetV1 = SyncEntityType._(r'SyncResetV1');
static const syncCompleteV1 = SyncEntityType._(r'SyncCompleteV1');
/// List of all possible values in this [enum][SyncEntityType].
static const values = <SyncEntityType>[
@ -118,6 +119,7 @@ class SyncEntityType {
userMetadataDeleteV1,
syncAckV1,
syncResetV1,
syncCompleteV1,
];
static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value);
@ -202,6 +204,7 @@ class SyncEntityTypeTypeTransformer {
case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1;
case r'SyncAckV1': return SyncEntityType.syncAckV1;
case r'SyncResetV1': return SyncEntityType.syncResetV1;
case r'SyncCompleteV1': return SyncEntityType.syncCompleteV1;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View file

@ -13,12 +13,9 @@ import 'package:pigeon/pigeon.dart';
)
@HostApi()
abstract class BackgroundWorkerFgHostApi {
void enableSyncWorker();
void enable();
void enableUploadWorker();
// Disables the background upload service
void disableUploadWorker();
void disable();
}
@HostApi()
@ -27,15 +24,12 @@ abstract class BackgroundWorkerBgHostApi {
// required platform channels to notify the native side to start the background upload
void onInitialized();
// Called from the background flutter engine to request the native side to cleanup
void close();
}
@FlutterApi()
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
@async
void onIosUpload(bool isRefresh, int? maxSeconds);

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.140.1+3011
version: 1.141.0+3012
environment:
sdk: '>=3.8.0 <4.0.0'

View file

@ -11,6 +11,7 @@ import 'schema_v5.dart' as v5;
import 'schema_v6.dart' as v6;
import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -32,10 +33,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v7.DatabaseAtV7(db);
case 8:
return v8.DatabaseAtV8(db);
case 9:
return v9.DatabaseAtV9(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8];
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9];
}

File diff suppressed because it is too large Load diff

View file

@ -9790,7 +9790,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.140.1",
"version": "1.141.0",
"contact": {}
},
"tags": [],
@ -14571,6 +14571,10 @@
"query": {
"type": "string"
},
"queryAssetId": {
"format": "uuid",
"type": "string"
},
"rating": {
"maximum": 5,
"minimum": -1,
@ -14638,9 +14642,6 @@
"type": "boolean"
}
},
"required": [
"query"
],
"type": "object"
},
"SourceType": {
@ -15416,6 +15417,10 @@
],
"type": "object"
},
"SyncCompleteV1": {
"properties": {},
"type": "object"
},
"SyncEntityType": {
"enum": [
"AuthUserV1",
@ -15463,7 +15468,8 @@
"UserMetadataV1",
"UserMetadataDeleteV1",
"SyncAckV1",
"SyncResetV1"
"SyncResetV1",
"SyncCompleteV1"
],
"type": "string"
},

View file

@ -1 +1 @@
22.18.0
22.19.0

View file

@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.140.1",
"version": "1.141.0",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.17.1",
"@types/node": "^22.18.0",
"typescript": "^5.3.3"
},
"repository": {
@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "22.18.0"
"node": "22.19.0"
}
}

View file

@ -1,6 +1,6 @@
/**
* Immich
* 1.140.1
* 1.141.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@ -1014,7 +1014,8 @@ export type SmartSearchDto = {
model?: string | null;
page?: number;
personIds?: string[];
query: string;
query?: string;
queryAssetId?: string;
rating?: number;
size?: number;
state?: string | null;
@ -4921,7 +4922,8 @@ export enum SyncEntityType {
UserMetadataV1 = "UserMetadataV1",
UserMetadataDeleteV1 = "UserMetadataDeleteV1",
SyncAckV1 = "SyncAckV1",
SyncResetV1 = "SyncResetV1"
SyncResetV1 = "SyncResetV1",
SyncCompleteV1 = "SyncCompleteV1"
}
export enum SyncRequestType {
AlbumsV1 = "AlbumsV1",

1665
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,7 @@ onlyBuiltDependencies:
- '@tailwindcss/oxide'
overrides:
canvas: 2.11.2
sharp: ^0.34.2
sharp: ^0.34.3
packageExtensions:
nestjs-kysely:
dependencies:

View file

@ -1 +1 @@
22.18.0
22.19.0

View file

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.140.1",
"version": "1.141.0",
"description": "",
"author": "",
"private": true,
@ -103,7 +103,7 @@
"sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0",
"semver": "^7.6.2",
"sharp": "^0.34.2",
"sharp": "^0.34.3",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0",
@ -135,7 +135,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^22.13.14",
"@types/node": "^22.18.0",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
@ -173,9 +173,9 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "22.18.0"
"node": "22.19.0"
},
"overrides": {
"sharp": "^0.34.2"
"sharp": "^0.34.3"
}
}

View file

@ -1,4 +1,6 @@
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 { AssetMediaService } from 'src/services/asset-media.service';
import request from 'supertest';
@ -11,7 +13,7 @@ const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
deviceId: 'TEST',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
isFavorite: 'testing',
isFavorite: 'false',
duration: '0:00:00.000000',
};
@ -27,16 +29,20 @@ describe(AssetMediaController.name, () => {
let ctx: ControllerContext;
const assetData = Buffer.from('123');
const filename = 'example.png';
const service = mockBaseService(AssetMediaService);
beforeAll(async () => {
ctx = await controllerSetup(AssetMediaController, [
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
{ provide: AssetMediaService, useValue: mockBaseService(AssetMediaService) },
{ provide: AssetMediaService, useValue: service },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
service.uploadAsset.mockResolvedValue({ status: AssetMediaStatus.DUPLICATE, id: factory.uuid() });
ctx.reset();
});
@ -46,13 +52,61 @@ describe(AssetMediaController.name, () => {
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 () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest(['deviceAssetId must be a string', 'deviceAssetId should not be empty']),
);
});
it('should require `deviceId`', async () => {
@ -61,7 +115,7 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceId' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(factory.responses.badRequest(['deviceId must be a string', 'deviceId should not be empty']));
});
it('should require `fileCreatedAt`', async () => {
@ -70,25 +124,20 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest(['fileCreatedAt must be a Date instance', 'fileCreatedAt should not be empty']),
);
});
it('should require `fileModifiedAt`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'fileModifiedAt' }) });
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
});
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());
expect(body).toEqual(
factory.responses.badRequest(['fileModifiedAt must be a Date instance', 'fileModifiedAt should not be empty']),
);
});
it('should throw if `isFavorite` is not a boolean', async () => {
@ -97,16 +146,18 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value']));
});
it('should throw if `visibility` is not an enum', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), visibility: 'not-a-boolean' });
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('visibility must be one of the following values:')]),
);
});
// TODO figure out how to deal with `sendFile`

View file

@ -128,12 +128,6 @@ describe(SearchController.name, () => {
await request(ctx.getHttpServer()).post('/search/smart');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a query', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/smart').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['query should not be empty', 'query must be a string']));
});
});
describe('GET /search/explore', () => {

View file

@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { plainToInstance, Transform, Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
import { AssetVisibility } from 'src/enum';
@ -65,10 +66,18 @@ export class AssetMediaCreateDto extends AssetMediaBase {
@ValidateUUID({ optional: true })
livePhotoVideoId?: string;
@Transform(({ value }) => {
try {
const json = JSON.parse(value);
const items = Array.isArray(json) ? json : [json];
return items.map((item) => plainToInstance(AssetMetadataUpsertItemDto, item));
} catch {
throw new BadRequestException(['metadata must be valid JSON']);
}
})
@Optional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataUpsertItemDto)
@IsArray()
metadata!: AssetMetadataUpsertItemDto[];
@ApiProperty({ type: 'string', format: 'binary', required: false })

View file

@ -199,7 +199,12 @@ export class StatisticsSearchDto extends BaseSearchDto {
export class SmartSearchDto extends BaseSearchWithResultsDto {
@IsString()
@IsNotEmpty()
query!: string;
@Optional()
query?: string;
@ValidateUUID({ optional: true })
@Optional()
queryAssetId?: string;
@IsString()
@IsNotEmpty()

View file

@ -336,6 +336,9 @@ export class SyncAckV1 {}
@ExtraModel()
export class SyncResetV1 {}
@ExtraModel()
export class SyncCompleteV1 {}
export type SyncItem = {
[SyncEntityType.AuthUserV1]: SyncAuthUserV1;
[SyncEntityType.UserV1]: SyncUserV1;
@ -382,6 +385,7 @@ export type SyncItem = {
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1;
[SyncEntityType.SyncCompleteV1]: SyncCompleteV1;
[SyncEntityType.SyncResetV1]: SyncResetV1;
};

View file

@ -530,6 +530,7 @@ export enum JobName {
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
AuditLogCleanup = 'AuditLogCleanup',
AuditTableCleanup = 'AuditTableCleanup',
DatabaseBackup = 'DatabaseBackup',
@ -570,8 +571,7 @@ export enum JobName {
SendMail = 'SendMail',
SidecarQueueAll = 'SidecarQueueAll',
SidecarDiscovery = 'SidecarDiscovery',
SidecarSync = 'SidecarSync',
SidecarCheck = 'SidecarCheck',
SidecarWrite = 'SidecarWrite',
SmartSearchQueueAll = 'SmartSearchQueueAll',
@ -708,6 +708,7 @@ export enum SyncEntityType {
SyncAckV1 = 'SyncAckV1',
SyncResetV1 = 'SyncResetV1',
SyncCompleteV1 = 'SyncCompleteV1',
}
export enum NotificationLevel {

View file

@ -43,6 +43,18 @@ where
limit
$2
-- AssetJobRepository.getForSidecarCheckJob
select
"id",
"sidecarPath",
"originalPath"
from
"asset"
where
"asset"."id" = $1::uuid
limit
$2
-- AssetJobRepository.streamForThumbnailJob
select
"asset"."id",

View file

@ -123,6 +123,14 @@ offset
$8
commit
-- SearchRepository.getEmbedding
select
*
from
"smart_search"
where
"assetId" = $1
-- SearchRepository.searchFaces
begin
set

View file

@ -957,7 +957,7 @@ where
order by
"stack"."updateId" asc
-- SyncRepository.people.getDeletes
-- SyncRepository.person.getDeletes
select
"id",
"personId"
@ -970,7 +970,7 @@ where
order by
"person_audit"."id" asc
-- SyncRepository.people.getUpserts
-- SyncRepository.person.getUpserts
select
"id",
"createdAt",

View file

@ -39,10 +39,8 @@ export class AssetJobRepository {
return this.db
.selectFrom('asset')
.where('asset.id', '=', asUuid(id))
.select((eb) => [
'id',
'sidecarPath',
'originalPath',
.select(['id', 'sidecarPath', 'originalPath'])
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('tag')
@ -50,7 +48,17 @@ export class AssetJobRepository {
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId')
.whereRef('asset.id', '=', 'tag_asset.assetsId'),
).as('tags'),
])
)
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForSidecarCheckJob(id: string) {
return this.db
.selectFrom('asset')
.where('asset.id', '=', asUuid(id))
.select(['id', 'sidecarPath', 'originalPath'])
.limit(1)
.executeTakeFirst();
}

View file

@ -141,6 +141,7 @@ export class MediaRepository {
failOn: options.processInvalidImages ? 'none' : 'error',
limitInputPixels: false,
raw: options.raw,
unlimited: true,
})
.pipelineColorspace(options.colorspace === Colorspace.Srgb ? 'srgb' : 'rgb16')
.withIccProfile(options.colorspace);

View file

@ -293,6 +293,13 @@ export class SearchRepository {
});
}
@GenerateSql({
params: [DummyValue.UUID],
})
async getEmbedding(assetId: string) {
return this.db.selectFrom('smart_search').selectAll().where('assetId', '=', assetId).executeTakeFirst();
}
@GenerateSql({
params: [
{

View file

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
@ -62,7 +62,7 @@ export class SyncRepository {
partnerAsset: PartnerAssetsSync;
partnerAssetExif: PartnerAssetExifsSync;
partnerStack: PartnerStackSync;
people: PersonSync;
person: PersonSync;
stack: StackSync;
user: UserSync;
userMetadata: UserMetadataSync;
@ -84,7 +84,7 @@ export class SyncRepository {
this.partnerAsset = new PartnerAssetsSync(this.db);
this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
this.partnerStack = new PartnerStackSync(this.db);
this.people = new PersonSync(this.db);
this.person = new PersonSync(this.db);
this.stack = new StackSync(this.db);
this.user = new UserSync(this.db);
this.userMetadata = new UserMetadataSync(this.db);
@ -117,6 +117,15 @@ class BaseSync {
.orderBy(idRef, 'asc');
}
protected auditCleanup<T extends keyof DB>(t: T, days: number) {
const { table, ref } = this.db.dynamic;
return this.db
.deleteFrom(table(t).as(t))
.where(ref(`${t}.deletedAt`), '<', sql.raw(`now() - interval '${days} days'`))
.execute();
}
protected upsertQuery<T extends keyof DB>(t: T, { nowId, ack }: SyncQueryOptions) {
const { table, ref } = this.db.dynamic;
const updateIdRef = ref(`${t}.updateId`);
@ -150,6 +159,10 @@ class AlbumSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('album_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
const userId = options.userId;
@ -286,6 +299,10 @@ class AlbumToAssetSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('album_asset_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
const userId = options.userId;
@ -334,6 +351,10 @@ class AlbumUserSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('album_user_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
const userId = options.userId;
@ -371,6 +392,10 @@ class AssetSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('asset_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('asset', options)
@ -400,6 +425,10 @@ class PersonSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('person_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('person', options)
@ -431,6 +460,10 @@ class AssetFaceSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('asset_face_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('asset_face', options)
@ -473,6 +506,10 @@ class MemorySync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('memory_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('memory', options)
@ -505,6 +542,10 @@ class MemoryToAssetSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('memory_asset_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('memory_asset', options)
@ -537,6 +578,10 @@ class PartnerSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('partner_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
const userId = options.userId;
@ -616,6 +661,10 @@ class StackSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('stack_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('stack', options)
@ -664,6 +713,10 @@ class UserSync extends BaseSync {
return this.auditQuery('user_audit', options).select(['id', 'userId']).stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('user_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('user', options).select(columns.syncUser).stream();
@ -679,6 +732,10 @@ class UserMetadataSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('user_metadata_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('user_metadata', options)
@ -698,6 +755,10 @@ class AssetMetadataSync extends BaseSync {
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('asset_metadata_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
getUpserts(options: SyncQueryOptions, userId: string) {
return this.upsertQuery('asset_metadata', options)

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