Merge branch 'main' into album-delete-sync

This commit is contained in:
Paweł Wojtaszko 2025-08-30 11:06:28 +02:00 committed by GitHub
commit 9301f029d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
196 changed files with 6256 additions and 887 deletions

View file

@ -5,7 +5,8 @@
"immich-server",
"redis",
"database",
"immich-machine-learning"
"immich-machine-learning",
"init"
],
"dockerComposeFile": [
"../docker/docker-compose.dev.yml",

View file

@ -11,8 +11,22 @@ services:
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
immich-web:
env_file: !reset []
init:
env_file: !reset []
command: sh -c 'find /data -maxdepth 1 ! -path "/data/postgres" -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
immich-machine-learning:
env_file: !reset []
database:

View file

@ -11,7 +11,7 @@ run_cmd pnpm --filter immich install
log "Starting Nest API Server"
log ""
cd "${IMMICH_WORKSPACE}/server" || (
log "Immich workspace not found"jj
log "Immich workspace not found"
exit 1
)

View file

@ -569,7 +569,8 @@ jobs:
- name: Build the app
run: pnpm --filter immich build
- name: Run API generation
run: make open-api
run: ./bin/generate-open-api.sh
working-directory: open-api
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files

View file

@ -4,34 +4,13 @@ module.exports = {
if (!pkg.name) {
return pkg;
}
switch (pkg.name) {
case "exiftool-vendored":
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
// make exiftool-vendored.pl a regular dependency
pkg.dependencies["exiftool-vendored.pl"] =
pkg.optionalDependencies["exiftool-vendored.pl"];
delete pkg.optionalDependencies["exiftool-vendored.pl"];
}
break;
case "sharp":
const optionalDeps = Object.keys(pkg.optionalDependencies).filter(
(dep) => dep.startsWith("@img")
);
for (const dep of optionalDeps) {
// remove all optionalDependencies from sharp (they will be compiled from source), except:
// include the precompiled musl version of sharp, for web
// include precompiled linux-x64 version of sharp, for server (stage: web-prod)
// include precompiled linux-arm64 version of sharp, for server (stage: web-prod)
if (
dep.includes("musl") ||
dep.includes("linux-x64") ||
dep.includes("linux-arm64")
) {
continue;
}
delete pkg.optionalDependencies[dep];
}
break;
if (pkg.name === "exiftool-vendored") {
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
// make exiftool-vendored.pl a regular dependency
pkg.dependencies["exiftool-vendored.pl"] =
pkg.optionalDependencies["exiftool-vendored.pl"];
delete pkg.optionalDependencies["exiftool-vendored.pl"];
}
}
return pkg;
},

View file

@ -1,23 +1,23 @@
dev:
dev: prepare-volumes
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
dev-update:
dev-update: prepare-volumes
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale:
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:
dev-docs: prepare-volumes
npm --prefix docs run start
.PHONY: e2e
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e: prepare-volumes
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
e2e-update:
e2e-update: prepare-volumes
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-down:
@ -33,16 +33,16 @@ prod-scale:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
.PHONY: open-api
open-api:
open-api: prepare-volumes
cd ./open-api && bash ./bin/generate-open-api.sh
open-api-dart:
open-api-dart: prepare-volumes
cd ./open-api && bash ./bin/generate-open-api.sh dart
open-api-typescript:
open-api-typescript: prepare-volumes
cd ./open-api && bash ./bin/generate-open-api.sh typescript
sql:
sql: prepare-volumes
pnpm --filter immich run sync:sql
attach-server:
@ -51,6 +51,47 @@ attach-server:
renovate:
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
# Directories that need to be created for volumes or build output
VOLUME_DIRS = \
./.pnpm-store \
./web/.svelte-kit \
./web/node_modules \
./web/coverage \
./e2e/node_modules \
./docs/node_modules \
./server/node_modules \
./open-api/typescript-sdk/node_modules \
./.github/node_modules \
./node_modules \
./cli/node_modules
# Include .env file if it exists
-include docker/.env
# Helper function to chown, on error suggest remediation and exit
define safe_chown
if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \
true; \
else \
echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \
exit 1; \
fi;
endef
# create empty directories and chown
prepare-volumes:
@$(foreach dir,$(VOLUME_DIRS),mkdir -p $(dir);)
@$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R))
ifneq ($(UPLOAD_LOCATION),)
ifeq ($(filter /%,$(UPLOAD_LOCATION)),)
@mkdir -p "docker/$(UPLOAD_LOCATION)"
@$(call safe_chown,docker/$(UPLOAD_LOCATION),)
else
@mkdir -p "$(UPLOAD_LOCATION)"
@$(call safe_chown,$(UPLOAD_LOCATION),)
endif
endif
MODULES = e2e server web cli sdk docs .github
# directory to package name mapping function
@ -126,8 +167,9 @@ clean:
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
setup-server-dev: install-server
setup-web-dev: install-sdk build-sdk install-web

View file

@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.82",
"version": "2.2.85",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View file

@ -32,6 +32,17 @@ services:
- ${UPLOAD_LOCATION}/photos:/data
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
env_file:
- .env
environment:
@ -84,6 +95,17 @@ services:
- 24678:24678
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
ulimits:
nofile:
soft: 1048576
@ -163,13 +185,35 @@ services:
init:
container_name: init
image: busybox
image: busybox@sha256:ab33eacc8251e3807b85bb6dba570e4698c3998eca6f0fc2ccb60575a563ea74
env_file:
- .env
user: 0:0
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
command: sh -c 'find /data -maxdepth 1 -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
volumes:
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
volumes:
model-cache:
prometheus-data:
grafana-data:
pnpm-store:
server-node_modules:
web-node_modules:
github-node_modules:
cli-node_modules:
docs-node_modules:
e2e-node_modules:
sdk-node_modules:
app-node_modules:
sveltekit:
coverage:

View file

@ -1,5 +1,31 @@
# FAQ
## Commercial Guidelines
### Are you open to commercial partnerships and collaborations?
We are working to commercialize Immich and we'd love for you to help us by making Immich better. FUTO is dedicated to developing sustainable models for developing open source software for our customers. We want our customers to be delighted by the products our engineers deliver, and we want our engineers to be paid when they succeed.
If you wish to use Immich in a commercial product not owned by FUTO, we have the following requirements:
- Plugin Integrations: Integrations for other platforms are typically approved, provided proper notification is given.
- Reseller Partnerships: Must adhere to the guidelines outlined below regarding trademark usage, and proper representation.
- Strategic Collaborations: We welcome discussions about mutually beneficial partnerships that enhance the value proposition for both organizations.
### What are your guidelines for resellers and trademark usage?
For organizations seeking to resell Immich, we have established the following guidelines to protect our brand integrity and ensure proper representation.
- We request that resellers do not display our trademarks on their websites or marketing materials. If such usage is discovered, we will contact you to request removal.
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directy from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
## User
### How can I reset the admin password?

View file

@ -10,7 +10,7 @@ Unable to set `app.immich:///oauth-callback` as a valid redirect URI? See [Mobil
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
- [Authentik](https://integrations.goauthentik.io/media/immich/)
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
- [Okta](https://www.okta.com/openid-connect/)
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
@ -88,7 +88,7 @@ The `.well-known/openid-configuration` part of the url is optional and will be a
## Auto Launch
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?authLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?autoLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
## Mobile Redirect URI

View file

@ -33,7 +33,7 @@ Sometimes, an external library will not scan correctly. This can happen if Immic
- Are the permissions set correctly?
- Make sure you are using forward slashes (`/`) and not backward slashes.
To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_server bash` to a bash shell. If your import path is `/data/import/photos`, check it with `ls /data/import/photos`. Do the same check for the same in any microservices containers.
To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_server bash` to a bash shell. If your import path is `/mnt/photos`, check it with `ls /mnt/photos`. If you are using a dedicated microservices container, make sure to add the same mount point and check for availability within the microservices container as well.
### Exclusion Patterns

View file

@ -1,4 +1,16 @@
[
{
"label": "v1.140.0",
"url": "https://v1.140.0.archive.immich.app"
},
{
"label": "v1.139.4",
"url": "https://v1.139.4.archive.immich.app"
},
{
"label": "v1.139.3",
"url": "https://v1.139.3.archive.immich.app"
},
{
"label": "v1.139.2",
"url": "https://v1.139.2.archive.immich.app"

View file

@ -38,7 +38,7 @@ services:
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:0e763a2383d56f90364fcd72767ac41400cd30d2627f407f7e7960c9f1923c21
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres

View file

@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.139.2",
"version": "1.140.0",
"description": "",
"main": "index.js",
"type": "module",

View file

@ -396,6 +396,8 @@
"advanced_settings_prefer_remote_title": "Prefer remote images",
"advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request",
"advanced_settings_proxy_headers_title": "Proxy Headers",
"advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen",
"advanced_settings_readonly_mode_title": "Read-only Mode",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
@ -461,6 +463,7 @@
"app_bar_signout_dialog_title": "Sign out",
"app_settings": "App Settings",
"appears_in": "Appears in",
"apply_count": "Apply ({count, number})",
"archive": "Archive",
"archive_action_prompt": "{count} added to Archive",
"archive_or_unarchive_photo": "Archive or unarchive photo",
@ -500,7 +503,7 @@
"assets": "Assets",
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal} albums",
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
"assets_count": "{count, plural, one {# asset} other {# assets}}",
@ -1073,12 +1076,18 @@
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
"general": "General",
"geolocation_instruction_all_have_location": "All assets for this date already have location data. Try showing all assets or select a different date",
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
"geolocation_instruction_no_date": "Select a date to manage location data for photos and videos from that day",
"geolocation_instruction_no_photos": "No photos or videos found for this date. Select a different date to show them",
"get_help": "Get Help",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "Getting Started",
"go_back": "Go back",
"go_to_folder": "Go to folder",
"go_to_search": "Go to search",
"gps": "GPS",
"gps_missing": "No GPS",
"grant_permission": "Grant permission",
"group_albums_by": "Group albums by...",
"group_country": "Group by country",
@ -1262,6 +1271,7 @@
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu",
"make": "Make",
"manage_geolocation": "Manage location",
"manage_shared_links": "Manage shared links",
"manage_sharing_with_partners": "Manage sharing with partners",
"manage_the_app_settings": "Manage the app settings",
@ -1508,6 +1518,7 @@
"profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.",
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
"profile_drawer_github": "GitHub",
"profile_drawer_readonly_mode": "Read-only mode enabled. Double-tap the user avatar icon to exit.",
"profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.",
"profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.",
"profile_image_of_user": "Profile image of {user}",
@ -1553,6 +1564,8 @@
"rating_description": "Display the EXIF rating in the info panel",
"reaction_options": "Reaction options",
"read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled",
"readonly_mode_enabled": "Read-only mode enabled",
"reassign": "Reassign",
"reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}",
"reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person",
@ -1722,6 +1735,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",
"send_message": "Send message",
"send_welcome_email": "Send welcome email",
"server_endpoint": "Server Endpoint",
@ -1832,8 +1846,10 @@
"shift_to_permanent_delete": "press ⇧ to permanently delete asset",
"show_album_options": "Show album options",
"show_albums": "Show albums",
"show_all_assets": "Show all assets",
"show_all_people": "Show all people",
"show_and_hide_people": "Show & hide people",
"show_assets_without_location": "Show assets without location",
"show_file_location": "Show file location",
"show_gallery": "Show gallery",
"show_hidden_people": "Show hidden people",
@ -1941,7 +1957,9 @@
"to_change_password": "Change password",
"to_favorite": "Favorite",
"to_login": "Login",
"to_multi_select": "to multi-select",
"to_parent": "Go to parent",
"to_select": "to select",
"to_trash": "Trash",
"toggle_settings": "Toggle settings",
"total": "Total",
@ -1991,6 +2009,7 @@
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"untagged": "Untagged",
"up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated",
"updated_password": "Updated password",
"upload": "Upload",
@ -2015,6 +2034,7 @@
"use_biometric": "Use biometric",
"use_current_connection": "use current connection",
"use_custom_date_range": "Use custom date range instead",
"use_this_location": "Click to use location",
"user": "User",
"user_has_been_deleted": "This user has been deleted.",
"user_id": "User ID",

View file

@ -65,7 +65,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
pnpm install --frozen-lockfile --prefix server
pnpm --prefix server run build
make open-api
( cd ./open-api && bash ./bin/generate-open-api.sh )
jq --arg version "$NEXT_SERVER" '.version = $version' open-api/typescript-sdk/package.json > open-api/typescript-sdk/package.json.tmp && mv open-api/typescript-sdk/package.json.tmp open-api/typescript-sdk/package.json

View file

@ -81,6 +81,7 @@ custom_lint:
# acceptable exceptions for the time being (until Isar is fully replaced)
- lib/providers/app_life_cycle.provider.dart
- integration_test/test_utils/general_helper.dart
- lib/domain/services/background_worker.service.dart
- lib/main.dart
- lib/pages/album/album_asset_selection.page.dart
- lib/routing/router.dart

View file

@ -5,15 +5,15 @@ import androidx.work.Configuration
import androidx.work.WorkManager
class ImmichApp : Application() {
override fun onCreate() {
super.onCreate()
val config = Configuration.Builder().build()
WorkManager.initialize(this, config)
// always start BackupWorker after WorkManager init; this fixes the following bug:
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
// (because of low memory etc.), the backup is never performed.
// As a workaround, we also run a backup check when initializing the application
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
}
override fun onCreate() {
super.onCreate()
val config = Configuration.Builder().build()
WorkManager.initialize(this, config)
// always start BackupWorker after WorkManager init; this fixes the following bug:
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
// (because of low memory etc.), the backup is never performed.
// As a workaround, we also run a backup check when initializing the application
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
}
}

View file

@ -1,8 +1,10 @@
package app.alextran.immich
import android.content.Context
import android.os.Build
import android.os.ext.SdkExtensions
import androidx.annotation.NonNull
import app.alextran.immich.background.BackgroundWorkerApiImpl
import app.alextran.immich.background.BackgroundWorkerFgHostApi
import app.alextran.immich.images.ThumbnailApi
import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.sync.NativeSyncApi
@ -12,19 +14,26 @@ import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
// No need to set up method channel here as it's now handled in the plugin
registerPlugins(this, flutterEngine)
}
val nativeSyncApiImpl =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
NativeSyncApiImpl26(this)
} else {
NativeSyncApiImpl30(this)
}
NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl)
ThumbnailApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ThumbnailsImpl(this))
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger
val nativeSyncApiImpl =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
NativeSyncApiImpl26(ctx)
} else {
NativeSyncApiImpl30(ctx)
}
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
}
}
}

View file

@ -0,0 +1,238 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.background
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object BackgroundWorkerPigeonUtils {
fun createConnectionError(channelName: String): FlutterError {
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerFgHostApi {
fun enableSyncWorker()
fun enableUploadWorker(callbackHandle: Long)
fun disableUploadWorker()
companion object {
/** The codec used by BackgroundWorkerFgHostApi. */
val codec: MessageCodec<Any?> by lazy {
BackgroundWorkerPigeonCodec()
}
/** Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
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)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.enableSyncWorker()
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.enableUploadWorker$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val callbackHandleArg = args[0] as Long
val wrapped: List<Any?> = try {
api.enableUploadWorker(callbackHandleArg)
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.disableUploadWorker()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerBgHostApi {
fun onInitialized()
companion object {
/** The codec used by BackgroundWorkerBgHostApi. */
val codec: MessageCodec<Any?> by lazy {
BackgroundWorkerPigeonCodec()
}
/** Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.onInitialized()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
/** The codec used by BackgroundWorkerFlutterApi. */
val codec: MessageCodec<Any?> by lazy {
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 ""
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(isRefreshArg, 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 onAndroidUpload(callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(null) {
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 cancel(callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(null) {
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)))
}
}
}
}

View file

@ -0,0 +1,162 @@
package app.alextran.immich.background
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import app.alextran.immich.MainActivity
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor.DartCallback
import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.view.FlutterCallbackInformation
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
/// The Flutter loader that loads the native Flutter library and resources.
/// This must be initialized before starting the Flutter engine.
private var loader: FlutterLoader = FlutterInjector.instance().flutterLoader()
/// The Flutter engine created specifically for background execution.
/// This is a separate instance from the main Flutter engine that handles the UI.
/// It operates in its own isolate and doesn't share memory with the main engine.
/// Must be properly started, registered, and torn down during background execution.
private var engine: FlutterEngine? = null
// Used to call methods on the flutter side
private var flutterApi: BackgroundWorkerFlutterApi? = null
/// Result returned when the background task completes. This is used to signal
/// to the WorkManager that the task has finished, either successfully or with failure.
private val completionHandler: SettableFuture<Result> = SettableFuture.create()
/// Flag to track whether the background task has completed to prevent duplicate completions
private var isComplete = false
init {
if (!loader.initialized()) {
loader.startInitialization(ctx)
}
}
override fun startWork(): ListenableFuture<Result> {
Log.i(TAG, "Starting background upload worker")
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
engine = FlutterEngine(ctx)
// Retrieve the callback handle stored by the main Flutter app
// This handle points to the Flutter function that should be executed in the background
val callbackHandle =
ctx.getSharedPreferences(BackgroundWorkerApiImpl.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(BackgroundWorkerApiImpl.SHARED_PREF_CALLBACK_HANDLE, 0L)
if (callbackHandle == 0L) {
// Without a valid callback handle, we cannot start the Flutter background execution
complete(Result.failure())
return@ensureInitializationCompleteAsync
}
// Start the Flutter engine with the specified callback as the entry point
val callback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
if (callback == null) {
complete(Result.failure())
return@ensureInitializationCompleteAsync
}
// Register custom plugins
MainActivity.registerPlugins(ctx, engine!!)
flutterApi =
BackgroundWorkerFlutterApi(binaryMessenger = engine!!.dartExecutor.binaryMessenger)
BackgroundWorkerBgHostApi.setUp(
binaryMessenger = engine!!.dartExecutor.binaryMessenger,
api = this
)
engine!!.dartExecutor.executeDartCallback(
DartCallback(ctx.assets, loader.findAppBundlePath(), callback)
)
}
return completionHandler
}
/**
* Called by the Flutter side when it has finished initialization and is ready to receive commands.
* Routes the appropriate task type (refresh or processing) to the corresponding Flutter method.
* 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) }
}
}
/**
* Called when the system has to stop this worker because constraints are
* no longer met or the system needs resources for more important tasks
* This is also called when the worker has been explicitly cancelled or replaced
*/
override fun onStopped() {
Log.d(TAG, "About to stop BackupWorker")
if (isComplete) {
return
}
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
if (flutterApi != null) {
flutterApi?.cancel {
complete(Result.failure())
}
}
}
Handler(Looper.getMainLooper()).postDelayed({
complete(Result.failure())
}, 5000)
}
private fun handleHostResult(result: kotlin.Result<Unit>) {
if (isComplete) {
return
}
result.fold(
onSuccess = { _ -> complete(Result.success()) },
onFailure = { _ -> onStopped() }
)
}
/**
* Cleans up resources by destroying the Flutter engine context and invokes the completion handler.
* This method ensures that the background task is marked as complete, releases the Flutter engine,
* and notifies the caller of the task's success or failure. This is the final step in the
* background task lifecycle and should only be called once per task instance.
*
* - Parameter success: Indicates whether the background task completed successfully
*/
private fun complete(success: Result) {
isComplete = true
engine?.destroy()
flutterApi = null
completionHandler.set(success)
}
}

View file

@ -0,0 +1,92 @@
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
import java.util.concurrent.TimeUnit
private const val TAG = "BackgroundUploadImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
override fun enableSyncWorker() {
enqueueMediaObserver(ctx)
Log.i(TAG, "Scheduled media observer")
}
override fun enableUploadWorker(callbackHandle: Long) {
updateUploadEnabled(ctx, true)
updateCallbackHandle(ctx, callbackHandle)
Log.i(TAG, "Scheduled background upload tasks")
}
override fun disableUploadWorker() {
updateUploadEnabled(ctx, false)
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
Log.i(TAG, "Cancelled background upload tasks")
}
companion object {
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"
const val SHARED_PREF_CALLBACK_HANDLE = "Background::backup::callbackHandle"
private fun updateUploadEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
putBoolean(SHARED_PREF_BACKUP_ENABLED, enabled)
}
}
private fun updateCallbackHandle(context: Context, callbackHandle: Long) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
putLong(SHARED_PREF_CALLBACK_HANDLE, callbackHandle)
}
}
fun enqueueMediaObserver(ctx: Context) {
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)
.build()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
.setConstraints(constraints)
.build()
WorkManager.getInstance(ctx)
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
}
fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) {
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()
WorkManager.getInstance(ctx)
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
}
}
}

View file

@ -0,0 +1,34 @@
package app.alextran.immich.background
import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) {
private val ctx: Context = context.applicationContext
override fun doWork(): Result {
Log.i("MediaObserver", "Content change detected, starting background worker")
// 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)
}
}

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3007,
"android.injected.version.name" => "1.139.2",
"android.injected.version.code" => 3010,
"android.injected.version.name" => "1.140.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')

View file

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@ -16,6 +16,9 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; };
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; };
@ -92,6 +95,9 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
@ -235,6 +241,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
65DD438629917FAD0047FFA8 /* BackgroundSync */,
@ -252,6 +259,16 @@
path = Runner;
sourceTree = "<group>";
};
B21E34A62E5AF9760031FDB9 /* Background */ = {
isa = PBXGroup;
children = (
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */,
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */,
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */,
);
path = Background;
sourceTree = "<group>";
};
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
isa = PBXGroup;
children = (
@ -488,10 +505,14 @@
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";
@ -520,10 +541,14 @@
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";
@ -538,10 +563,13 @@
files = (
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -667,7 +695,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215;
CURRENT_PROJECT_VERSION = 218;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -811,7 +839,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215;
CURRENT_PROJECT_VERSION = 218;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -841,7 +869,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215;
CURRENT_PROJECT_VERSION = 218;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -875,7 +903,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215;
CURRENT_PROJECT_VERSION = 218;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@ -918,7 +946,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215;
CURRENT_PROJECT_VERSION = 218;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@ -958,7 +986,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215;
CURRENT_PROJECT_VERSION = 218;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@ -997,7 +1025,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215;
CURRENT_PROJECT_VERSION = 218;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -1041,7 +1069,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215;
CURRENT_PROJECT_VERSION = 218;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -1082,7 +1110,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215;
CURRENT_PROJECT_VERSION = 218;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View file

@ -19,13 +19,12 @@ import UIKit
}
GeneratedPluginRegistrant.register(with: self)
BackgroundServicePlugin.registerBackgroundProcessing()
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(binaryMessenger: controller.binaryMessenger)
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
NativeSyncApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: NativeSyncApiImpl())
ThumbnailApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ThumbnailApiImpl())
BackgroundServicePlugin.registerBackgroundProcessing()
BackgroundWorkerApiImpl.registerBackgroundProcessing()
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
@ -51,4 +50,10 @@ import UIKit
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
}
}

View file

@ -0,0 +1,245 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
return [
pigeonError.code,
pigeonError.message,
pigeonError.details,
]
}
if let flutterError = error as? FlutterError {
return [
flutterError.code,
flutterError.message,
flutterError.details,
]
}
return [
"\(error)",
"\(type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func createConnectionError(withChannelName channelName: String) -> PigeonError {
return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "")
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
}
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
}
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return BackgroundWorkerPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return BackgroundWorkerPigeonCodecWriter(data: data)
}
}
class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enableSyncWorker() throws
func enableUploadWorker(callbackHandle: Int64) throws
func disableUploadWorker() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class BackgroundWorkerFgHostApiSetup {
static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared }
/// 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)
if let api = api {
enableSyncWorkerChannel.setMessageHandler { _, reply in
do {
try api.enableSyncWorker()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
enableSyncWorkerChannel.setMessageHandler(nil)
}
let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
enableUploadWorkerChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let callbackHandleArg = args[0] as! Int64
do {
try api.enableUploadWorker(callbackHandle: callbackHandleArg)
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)
}
}
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerBgHostApi {
func onInitialized() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class BackgroundWorkerBgHostApiSetup {
static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared }
/// Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let onInitializedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
onInitializedChannel.setMessageHandler { _, reply in
do {
try api.onInitialized()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
onInitializedChannel.setMessageHandler(nil)
}
}
}
/// 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)
}
class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
private let binaryMessenger: FlutterBinaryMessenger
private let messageChannelSuffix: String
init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") {
self.binaryMessenger = binaryMessenger
self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
}
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)
channel.sendMessage([isRefreshArg, 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 onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
channel.sendMessage(nil) { 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 cancel(completion: @escaping (Result<Void, PigeonError>) -> Void) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
channel.sendMessage(nil) { 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(()))
}
}
}
}

View file

@ -0,0 +1,202 @@
import BackgroundTasks
import Flutter
enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
/*
* DEBUG: Testing Background Tasks in Xcode
*
* 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):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
## For background processing (long-running upload):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.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"]
* 3. Resume the application to see the background code execute
*
* NOTE: This must be tested on a physical device, not in the simulator.
* In testing, only the background processing task can be reliably simulated.
* These commands submit the respective task to BGTaskScheduler for immediate processing.
* Use the expiration commands to test how the app handles iOS terminating background tasks.
*/
/// The background worker which creates a new Flutter VM, communicates with it
/// to run the backup job, and then finishes execution and calls back to its callback handler.
/// This class manages a separate Flutter engine instance for background execution,
/// independent of the main UI Flutter engine.
class BackgroundWorker: BackgroundWorkerBgHostApi {
private let taskType: BackgroundTaskType
/// The maximum number of seconds to run the task before timing out
private let maxSeconds: Int?
/// Callback function to invoke when the background task completes
private let completionHandler: (_ success: Bool) -> Void
/// The Flutter engine created specifically for background execution.
/// This is a separate instance from the main Flutter engine that handles the UI.
/// It operates in its own isolate and doesn't share memory with the main engine.
/// Must be properly started, registered, and torn down during background execution.
private let engine = FlutterEngine(name: "BackgroundImmich")
/// Used to call methods on the flutter side
private var flutterApi: BackgroundWorkerFlutterApi?
/// Flag to track whether the background task has completed to prevent duplicate completions
private var isComplete = false
/**
* Initializes a new background worker with the specified task type and execution constraints.
* Creates a new Flutter engine instance for background execution and sets up the necessary
* communication channels between native iOS and Flutter code.
*
* - Parameters:
* - taskType: The type of background task to execute (upload or sync task)
* - maxSeconds: Optional maximum execution time in seconds before the task is cancelled
* - completionHandler: Callback function invoked when the task completes, with success status
*/
init(taskType: BackgroundTaskType, maxSeconds: Int?, completionHandler: @escaping (_ success: Bool) -> Void) {
self.taskType = taskType
self.maxSeconds = maxSeconds
self.completionHandler = completionHandler
// Should be initialized only after the engine starts running
self.flutterApi = nil
}
/**
* Starts the background Flutter engine and begins execution of the background task.
* Retrieves the callback handle from UserDefaults, looks up the Flutter callback,
* starts the engine, and sets up a timeout timer if specified.
*/
func run() {
// Retrieve the callback handle stored by the main Flutter app
// This handle points to the Flutter function that should be executed in the background
let callbackHandle = Int64(UserDefaults.standard.string(
forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) ?? "0") ?? 0
if callbackHandle == 0 {
// Without a valid callback handle, we cannot start the Flutter background execution
complete(success: false)
return
}
// Use the callback handle to retrieve the actual Flutter callback information
guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else {
// The callback handle is invalid or the callback was not found
complete(success: false)
return
}
// Start the Flutter engine with the specified callback as the entry point
let isRunning = engine.run(
withEntrypoint: callback.callbackName,
libraryURI: callback.callbackLibraryPath
)
// Verify that the Flutter engine started successfully
if !isRunning {
complete(success: false)
return
}
// Register plugins in the new engine
GeneratedPluginRegistrant.register(with: engine)
// Register custom plugins
AppDelegate.registerPlugins(binaryMessenger: engine.binaryMessenger)
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
// Set up a timeout timer if maxSeconds was specified to prevent runaway background tasks
if maxSeconds != nil {
// Schedule a timer to cancel the task after the specified timeout period
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
self.cancel()
}
}
}
/**
* Called by the Flutter side when it has finished initialization and is ready to receive commands.
* Routes the appropriate task type (refresh or processing) to the corresponding Flutter method.
* 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)
})
}
}
/**
* 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
* the completion handler is eventually called even if Flutter doesn't respond.
*/
func cancel() {
if isComplete {
return
}
isComplete = true
flutterApi?.cancel { result in
self.complete(success: false)
}
// Fallback safety mechanism: ensure completion is called within 2 seconds
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
self.complete(success: false)
}
}
/**
* Handles the result from Flutter API calls and determines the success/failure status.
* Converts Flutter's Result type to a simple boolean success indicator for task completion.
*
* - Parameter result: The result returned from a Flutter API call
*/
private func handleHostResult(result: Result<Void, PigeonError>) {
switch result {
case .success(): self.complete(success: true)
case .failure(_): self.cancel()
}
}
/**
* Cleans up resources by destroying the Flutter engine context and invokes the completion handler.
* This method ensures that the background task is marked as complete, releases the Flutter engine,
* and notifies the caller of the task's success or failure. This is the final step in the
* background task lifecycle and should only be called once per task instance.
*
* - Parameter success: Indicates whether the background task completed successfully
*/
private func complete(success: Bool) {
isComplete = true
engine.destroyContext()
completionHandler(success)
}
}

View file

@ -0,0 +1,155 @@
import BackgroundTasks
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enableSyncWorker() throws {
BackgroundWorkerApiImpl.scheduleLocalSync()
print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled")
}
func enableUploadWorker(callbackHandle: Int64) throws {
BackgroundWorkerApiImpl.updateUploadEnabled(true)
// Store the callback handle for later use when starting background Flutter isolates
BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle)
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"
public static let backgroundUploadCallbackHandleKey = "immich:background:backup:callbackHandle"
private static let localSyncTaskID = "app.alextran.immich.background.localSync"
private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingUploadTaskID = "app.alextran.immich.background.processingUpload"
private static func updateUploadEnabled(_ isEnabled: Bool) {
return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey)
}
private static func updateUploadCallbackHandle(_ callbackHandle: Int64) {
return UserDefaults.standard.set(String(callbackHandle), forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey)
}
private static func cancelUploadTasks() {
BackgroundWorkerApiImpl.updateUploadEnabled(false)
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID);
}
public static func registerBackgroundProcessing() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in
if task is BGProcessingTask {
handleBackgroundProcessing(task: task as! BGProcessingTask)
}
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in
if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload)
}
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: localSyncTaskID, using: nil) { task in
if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .localSync)
}
}
}
private static func scheduleLocalSync() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: localSyncTaskID)
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
do {
try BGTaskScheduler.shared.submit(backgroundRefresh)
} catch {
print("Could not schedule the local sync task \(error.localizedDescription)")
}
}
private static func scheduleRefreshUpload() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID)
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
do {
try BGTaskScheduler.shared.submit(backgroundRefresh)
} catch {
print("Could not schedule the refresh upload task \(error.localizedDescription)")
}
}
private static func scheduleProcessingUpload() {
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID)
backgroundProcessing.requiresNetworkConnectivity = true
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
do {
try BGTaskScheduler.shared.submit(backgroundProcessing)
} catch {
print("Could not schedule the processing upload task \(error.localizedDescription)")
}
}
private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) {
scheduleRefreshUpload()
// Restrict the refresh task to run only for a maximum of 20 seconds
runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20)
}
private static func handleBackgroundProcessing(task: BGProcessingTask) {
scheduleProcessingUpload()
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil)
}
/**
* Executes the background worker within the context of a background task.
* This method creates a BackgroundWorker, sets up task expiration handling,
* and manages the synchronization between the background task and the Flutter engine.
*
* - Parameters:
* - task: The iOS background task that provides the execution context
* - taskType: The type of background operation to perform (refresh or processing)
* - maxSeconds: Optional timeout for the operation in seconds
*/
private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) {
let semaphore = DispatchSemaphore(value: 0)
var isSuccess = true
let backgroundWorker = BackgroundWorker(taskType: taskType, maxSeconds: maxSeconds) { success in
isSuccess = success
semaphore.signal()
}
task.expirationHandler = {
DispatchQueue.main.async {
backgroundWorker.cancel()
}
isSuccess = false
// Schedule a timer to signal the semaphore after 2 seconds
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
semaphore.signal()
}
}
DispatchQueue.main.async {
backgroundWorker.run()
}
semaphore.wait()
task.setTaskCompleted(success: isSuccess)
print("Background task completed with success: \(isSuccess)")
}
}

View file

@ -6,6 +6,9 @@
<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>
@ -78,7 +81,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.138.1</string>
<string>1.139.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -105,7 +108,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>215</string>
<string>217</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
@ -134,6 +137,9 @@
<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>
@ -180,8 +186,5 @@
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
<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>
</dict>
</plist>

View file

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

View file

@ -67,6 +67,9 @@ enum StoreKey<T> {
loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
// Read-only Mode settings
readonlyModeEnabled<bool>._(138),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001),

View file

@ -75,6 +75,8 @@ profileChangedAt: $profileChangedAt
bool? isPartnerSharedWith,
bool? hasProfileImage,
DateTime? profileChangedAt,
int? quotaSizeInBytes,
int? quotaUsageInBytes,
}) => UserDto(
id: id ?? this.id,
email: email ?? this.email,
@ -88,6 +90,8 @@ profileChangedAt: $profileChangedAt
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes,
quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes,
);
@override
@ -105,7 +109,9 @@ profileChangedAt: $profileChangedAt
other.memoryEnabled == memoryEnabled &&
other.inTimeline == inTimeline &&
other.hasProfileImage == hasProfileImage &&
other.profileChangedAt.isAtSameMomentAs(profileChangedAt);
other.profileChangedAt.isAtSameMomentAs(profileChangedAt) &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes;
}
@override
@ -121,7 +127,9 @@ profileChangedAt: $profileChangedAt
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
hasProfileImage.hashCode ^
profileChangedAt.hashCode;
profileChangedAt.hashCode ^
quotaSizeInBytes.hashCode ^
quotaUsageInBytes.hashCode;
}
class PartnerUserDto {

View file

@ -74,7 +74,6 @@ isOnboarded: $isOnboarded,
int get hashCode => isOnboarded.hashCode;
}
// TODO: wait to be overwritten
class Preferences {
final bool foldersEnabled;
final bool memoriesEnabled;
@ -133,17 +132,17 @@ class Preferences {
factory Preferences.fromMap(Map<String, Object?> map) {
return Preferences(
foldersEnabled: map["folders-Enabled"] as bool? ?? false,
memoriesEnabled: map["memories-Enabled"] as bool? ?? true,
peopleEnabled: map["people-Enabled"] as bool? ?? true,
ratingsEnabled: map["ratings-Enabled"] as bool? ?? false,
sharedLinksEnabled: map["sharedLinks-Enabled"] as bool? ?? true,
tagsEnabled: map["tags-Enabled"] as bool? ?? false,
foldersEnabled: (map["folders"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
memoriesEnabled: (map["memories"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
peopleEnabled: (map["people"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
ratingsEnabled: (map["ratings"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
sharedLinksEnabled: (map["sharedLinks"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
tagsEnabled: (map["tags"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
userAvatarColor: AvatarColor.values.firstWhere(
(e) => e.value == map["avatar-Color"] as String?,
(e) => e.value == (map["avatar"] as Map<String, Object?>?)?["color"] as String?,
orElse: () => AvatarColor.primary,
),
showSupportBadge: map["purchase-ShowSupportBadge"] as bool? ?? true,
showSupportBadge: (map["purchase"] as Map<String, Object?>?)?["showSupportBadge"] as bool? ?? true,
);
}
@ -213,7 +212,7 @@ class License {
factory License.fromMap(Map<String, Object?> map) {
return License(
activatedAt: map["activatedAt"] as DateTime,
activatedAt: DateTime.parse(map["activatedAt"] as String),
activationKey: map["activationKey"] as String,
licenseKey: map["licenseKey"] as String,
);

View file

@ -17,9 +17,14 @@ class AssetService {
_localAssetRepository = localAssetRepository,
_platform = const LocalPlatform();
Future<BaseAsset?> getAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id);
}
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset ? _localAssetRepository.watchAsset(id) : _remoteAssetRepository.watchAsset(id);
return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
}
Future<RemoteAsset?> getRemoteAsset(String id) {

View file

@ -0,0 +1,232 @@
import 'dart:async';
import 'dart:ui';
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/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';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
class BackgroundWorkerFgService {
final BackgroundWorkerFgHostApi _foregroundHostApi;
const BackgroundWorkerFgService(this._foregroundHostApi);
// TODO: Move this call to native side once old timeline is removed
Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker();
Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker(
PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(),
);
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
}
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
late final ProviderContainer _ref;
final Isar _isar;
final Drift _drift;
final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi;
final Logger _logger = Logger('BackgroundUploadBgService');
bool _isCleanedUp = false;
BackgroundWorkerBgService({required Isar isar, required Drift drift, required DriftLogger driftLogger})
: _isar = isar,
_drift = drift,
_driftLogger = driftLogger,
_backgroundHostApi = BackgroundWorkerBgHostApi() {
_ref = ProviderContainer(
overrides: [
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
);
BackgroundWorkerFlutterApi.setUp(this);
}
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
Future<void> init() async {
await loadTranslations();
HttpSSLOptions.apply(applyNative: false);
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
// Initialize the file downloader
await FileDownloader().configure(
globalConfig: [
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
(Config.holdingQueue, (6, 6, 3)),
// On Android, if files are larger than 256MB, run in foreground service
(Config.runInForegroundIfFileLargerThan, 256),
],
);
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
await FileDownloader().trackTasks();
configureFileDownloaderNotifications();
// Notify the host that the background upload service has been initialized and is ready to use
await _backgroundHostApi.onInitialized();
}
@override
Future<void> onLocalSync(int? maxSeconds) async {
_logger.info('Local background syncing started');
final sw = Stopwatch()..start();
final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null;
await _syncAssets(hashTimeout: timeout, syncRemote: false);
sw.stop();
_logger.info("Local sync completed in ${sw.elapsed.inSeconds}s");
}
/* We do the following on Android upload
* - Sync local assets
* - Hash local assets 3 / 6 minutes
* - Sync remote assets
* - Check and requeue upload tasks
*/
@override
Future<void> onAndroidUpload() async {
_logger.info('Android background processing started');
final sw = Stopwatch()..start();
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
await _handleBackup(processBulk: false);
await _cleanup();
sw.stop();
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
}
/* 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 {
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
final sw = Stopwatch()..start();
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
await _syncAssets(hashTimeout: timeout);
final backupFuture = _handleBackup();
if (maxSeconds != null) {
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
} else {
await backupFuture;
}
await _cleanup();
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
}
@override
Future<void> cancel() async {
_logger.warning("Background upload cancelled");
await _cleanup();
}
Future<void> _cleanup() async {
if (_isCleanedUp) {
return;
}
_isCleanedUp = true;
await _ref.read(backgroundSyncProvider).cancel();
await _ref.read(backgroundSyncProvider).cancelLocal();
await _isar.close();
await _drift.close();
await _driftLogger.close();
_ref.dispose();
}
Future<void> _handleBackup({bool processBulk = true}) async {
if (!_isBackupEnabled) {
return;
}
final currentUser = _ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
if (processBulk) {
return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
}
final activeTask = await _ref.read(uploadServiceProvider).getActiveTasks(currentUser.id);
if (activeTask.isNotEmpty) {
await _ref.read(uploadServiceProvider).resumeBackup();
} else {
await _ref.read(uploadServiceProvider).startBackupSerial(currentUser.id);
}
}
Future<void> _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async {
final futures = <Future<void>>[];
final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async {
if (_isCleanedUp) {
return;
}
var hashFuture = _ref.read(backgroundSyncProvider).hashAssets();
if (hashTimeout != null) {
hashFuture = hashFuture.timeout(
hashTimeout,
onTimeout: () {
// Consume cancellation errors as we want to continue processing
},
);
}
return hashFuture;
});
futures.add(localSyncFuture);
if (syncRemote) {
final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote();
futures.add(remoteSyncFuture);
}
await Future.wait(futures);
}
}
@pragma('vm:entry-point')
Future<void> _backgroundSyncNativeEntrypoint() async {
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
final (isar, drift, logDB) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDB, shouldBufferLogs: false);
await BackgroundWorkerBgService(isar: isar, drift: drift, driftLogger: logDB).init();
}

View file

@ -15,6 +15,7 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository;
final StorageRepository _storageRepository;
final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
HashService({
@ -22,13 +23,17 @@ class HashService {
required DriftLocalAssetRepository localAssetRepository,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker,
this.batchSizeLimit = kBatchHashSizeLimit,
this.batchFileLimit = kBatchHashFileLimit,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_storageRepository = storageRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async {
final Stopwatch stopwatch = Stopwatch()..start();
// Sorted by backupSelection followed by isCloud
@ -37,6 +42,11 @@ class HashService {
);
for (final album in localAlbums) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing albums.");
break;
}
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) {
await _hashAssets(assetsToHash);
@ -55,6 +65,11 @@ class HashService {
final toHash = <_AssetToPath>[];
for (final asset in assetsToHash) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing assets.");
return;
}
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
continue;
@ -89,6 +104,11 @@ class HashService {
);
for (int i = 0; i < hashes.length; i++) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing batch.");
return;
}
final hash = hashes[i];
final asset = toHash[i].asset;
if (hash?.length == 20) {

View file

@ -123,6 +123,11 @@ class LogService {
_flushTimer = null;
final buffer = [..._msgBuffer];
_msgBuffer.clear();
if (buffer.isEmpty) {
return;
}
await _logRepository.insertAll(buffer);
}
}

View file

@ -59,6 +59,28 @@ class BackgroundSyncManager {
}
}
Future<void> cancelLocal() async {
final futures = <Future>[];
if (_hashTask != null) {
futures.add(_hashTask!.future);
}
_hashTask?.cancel();
_hashTask = null;
if (_deviceAlbumSyncTask != null) {
futures.add(_deviceAlbumSyncTask!.future);
}
_deviceAlbumSyncTask?.cancel();
_deviceAlbumSyncTask = null;
try {
await Future.wait(futures);
} on CanceledError {
// Ignore cancellation errors
}
}
// No need to cancel the task, as it can also be run when the user logs out
Future<void> syncLocal({bool full = false}) {
if (_deviceAlbumSyncTask != null) {

View file

@ -54,6 +54,8 @@ class User {
avatarColor: dto.avatarColor,
memoryEnabled: dto.memoryEnabled,
inTimeline: dto.inTimeline,
quotaUsageInBytes: dto.quotaUsageInBytes,
quotaSizeInBytes: dto.quotaSizeInBytes,
);
UserDto toDto() => UserDto(

View file

@ -64,19 +64,62 @@ class RemoteImageRequest extends ImageRequest {
if (_isCancelled) {
return null;
}
final bytes = Uint8List(response.contentLength);
int offset = 0;
final subscription = response.listen((List<int> chunk) {
// this is important to break the response stream if the request is cancelled
final cacheManager = this.cacheManager;
final streamController = StreamController<List<int>>(sync: true);
final Stream<List<int>> stream;
cacheManager?.putStreamedFile(url, streamController.stream);
stream = response.map((chunk) {
if (_isCancelled) {
throw StateError('Cancelled request');
}
bytes.setAll(offset, chunk);
offset += chunk.length;
}, cancelOnError: true);
cacheManager?.putStreamedFile(url, response);
await subscription.asFuture();
return await ImmutableBuffer.fromUint8List(bytes);
if (cacheManager != null) {
streamController.add(chunk);
}
return chunk;
});
try {
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
streamController.close();
return await ImmutableBuffer.fromUint8List(bytes);
} catch (e) {
streamController.addError(e);
streamController.close();
if (_isCancelled) {
return null;
}
rethrow;
}
}
Future<Uint8List> _downloadBytes(Stream<List<int>> stream, int length) async {
final Uint8List bytes;
int offset = 0;
if (length > 0) {
// Known content length - use pre-allocated buffer
bytes = Uint8List(length);
await stream.listen((chunk) {
bytes.setAll(offset, chunk);
offset += chunk.length;
}, cancelOnError: true).asFuture();
} else {
// Unknown content length - collect chunks dynamically
final chunks = <List<int>>[];
int totalLength = 0;
await stream.listen((chunk) {
chunks.add(chunk);
totalLength += chunk.length;
}, cancelOnError: true).asFuture();
bytes = Uint8List(totalLength);
for (final chunk in chunks) {
bytes.setAll(offset, chunk);
offset += chunk.length;
}
}
return bytes;
}
Future<ImageInfo?> _loadCachedFile(

View file

@ -9,7 +9,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftLocalAssetRepository(this._db) : super(_db);
Stream<LocalAsset?> watchAsset(String id) {
SingleOrNullSelectable<LocalAsset?> _assetSelectable(String id) {
final query = _db.localAssetEntity.select().addColumns([_db.remoteAssetEntity.id]).join([
leftOuterJoin(
_db.remoteAssetEntity,
@ -21,9 +21,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((row) {
final asset = row.readTable(_db.localAssetEntity).toDto();
return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id));
}).watchSingleOrNull();
});
}
Future<LocalAsset?> get(String id) => _assetSelectable(id).getSingleOrNull();
Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
if (hashes.isEmpty) {
return Future.value();

View file

@ -15,8 +15,8 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
final query =
_db.select(_db.memoryEntity).join([
leftOuterJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
leftOuterJoin(
innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.id.equalsExp(_db.memoryAssetEntity.assetId) &
_db.remoteAssetEntity.deletedAt.isNull() &
@ -30,6 +30,9 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
final rows = await query.get();
if (rows.isEmpty) {
return const [];
}
final Map<String, DriftMemory> memoriesMap = {};
@ -46,7 +49,7 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
}
}
return memoriesMap.values.toList();
return memoriesMap.values.toList(growable: false);
}
Future<DriftMemory?> get(String memoryId) async {

View file

@ -17,7 +17,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
const DriftRemoteAlbumRepository(this._db) : super(_db);
Future<List<RemoteAlbum>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {SortRemoteAlbumsBy.updatedAt}}) {
final assetCount = _db.remoteAlbumAssetEntity.assetId.count();
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
final query = _db.remoteAlbumEntity.select().join([
leftOuterJoin(
@ -41,7 +41,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
..where(_db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount])
..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
..groupBy([_db.remoteAlbumEntity.id]);
if (sortBy.isNotEmpty) {
@ -62,14 +62,14 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.toDto(
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
),
)
.get();
}
Future<RemoteAlbum?> get(String albumId) {
final assetCount = _db.remoteAlbumAssetEntity.assetId.count();
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
final query =
_db.remoteAlbumEntity.select().join([
@ -97,7 +97,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount])
..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
..groupBy([_db.remoteAlbumEntity.id]);
return query
@ -107,7 +107,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.toDto(
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
),
)
.getSingleOrNull();
@ -282,7 +282,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
])
..where(_db.remoteAlbumEntity.id.equals(albumId))
..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
..groupBy([_db.remoteAlbumEntity.id]);
return query.map((row) {
@ -290,7 +290,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.readTable(_db.remoteAlbumEntity)
.toDto(
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
);
return album;
}).watchSingleOrNull();

View file

@ -55,24 +55,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
return _assetSelectable(id).getSingleOrNull();
}
Stream<RemoteAsset?> watchAsset(String id) {
final query =
_db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([
leftOuterJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
])
..where(_db.remoteAssetEntity.id.equals(id))
..limit(1);
return query.map((row) {
final asset = row.readTable(_db.remoteAssetEntity).toDto();
return asset.copyWith(localId: row.read(_db.localAssetEntity.id));
}).watchSingleOrNull();
}
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
if (asset.stackId == null) {
return Future.value([]);

View file

@ -1,9 +1,11 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
import 'package:isar/isar.dart';
class IsarUserRepository extends IsarDatabaseRepository {
@ -70,8 +72,16 @@ class DriftUserRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftUserRepository(super.db) : _db = db;
Future<UserDto?> get(String id) =>
_db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull().then((user) => user?.toDto());
Future<UserDto?> get(String id) async {
final user = await _db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull();
if (user == null) return null;
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(id));
final metadata = await query.map((row) => row.toDto()).get();
return user.toDto(metadata);
}
Future<UserDto> upsert(UserDto user) async {
await _db.userEntity.insertOnConflictUpdate(
@ -87,10 +97,35 @@ class DriftUserRepository extends DriftDatabaseRepository {
);
return user;
}
Future<List<UserDto>> getAll() async {
final users = await _db.userEntity.select().get();
final List<UserDto> result = [];
for (final user in users) {
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(user.id));
final metadata = await query.map((row) => row.toDto()).get();
result.add(user.toDto(metadata));
}
return result;
}
}
extension on UserEntityData {
UserDto toDto() {
UserDto toDto([List<UserMetadata>? metadata]) {
AvatarColor avatarColor = AvatarColor.primary;
bool memoryEnabled = true;
if (metadata != null) {
for (final meta in metadata) {
if (meta.key == UserMetadataKey.preferences && meta.preferences != null) {
avatarColor = meta.preferences?.userAvatarColor ?? AvatarColor.primary;
memoryEnabled = meta.preferences?.memoriesEnabled ?? true;
}
}
}
return UserDto(
id: id,
email: email,
@ -99,6 +134,8 @@ extension on UserEntityData {
updatedAt: updatedAt,
profileChangedAt: profileChangedAt,
hasProfileImage: hasProfileImage,
avatarColor: avatarColor,
memoryEnabled: memoryEnabled,
);
}
}

View file

@ -16,7 +16,7 @@ class DriftUserMetadataRepository extends DriftDatabaseRepository {
}
}
extension on UserMetadataEntityData {
extension UserMetadataDataExtension on UserMetadataEntityData {
UserMetadata toDto() => switch (key) {
UserMetadataKey.onboarding => UserMetadata(userId: userId, key: key, onboarding: Onboarding.fromMap(value)),
UserMetadataKey.preferences => UserMetadata(userId: userId, key: key, preferences: Preferences.fromMap(value)),

View file

@ -29,6 +29,8 @@ abstract final class UserConverter {
isPartnerSharedWith: false,
profileChangedAt: adminDto.profileChangedAt,
hasProfileImage: adminDto.profileImagePath.isNotEmpty,
quotaSizeInBytes: adminDto.quotaSizeInBytes ?? 0,
quotaUsageInBytes: adminDto.quotaUsageInBytes ?? 0,
);
static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto(

View file

@ -12,10 +12,13 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/locales.dart';
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';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
@ -23,6 +26,7 @@ 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';
@ -165,36 +169,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
await ref.read(localNotificationService).setup();
}
void _configureFileDownloaderNotifications() {
FileDownloader().configureNotificationForGroup(
kDownloadGroupImage,
running: TaskNotification('downloading_media'.tr(), '${'file_name'.tr()}: {filename}'),
complete: TaskNotification('download_finished'.tr(), '${'file_name'.tr()}: {filename}'),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kDownloadGroupVideo,
running: TaskNotification('downloading_media'.tr(), '${'file_name'.tr()}: {filename}'),
complete: TaskNotification('download_finished'.tr(), '${'file_name'.tr()}: {filename}'),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kManualUploadGroup,
running: TaskNotification('uploading_media'.tr(), '${'file_name'.tr()}: {displayName}'),
complete: TaskNotification('upload_finished'.tr(), '${'file_name'.tr()}: {displayName}'),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kBackupGroup,
running: TaskNotification('uploading_media'.tr(), '${'file_name'.tr()}: {displayName}'),
complete: TaskNotification('upload_finished'.tr(), '${'file_name'.tr()}: {displayName}'),
progressBar: true,
);
}
Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async {
final deepLinkHandler = ref.read(deepLinkServiceProvider);
final currentRouteName = ref.read(currentRouteNameProvider.notifier).state;
@ -221,7 +195,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
super.didChangeDependencies();
Intl.defaultLocale = context.locale.toLanguageTag();
WidgetsBinding.instance.addPostFrameCallback((_) {
_configureFileDownloaderNotifications();
configureFileDownloaderNotifications();
});
}
@ -231,7 +205,16 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
initApp().then((_) => debugPrint("App Init Completed"));
WidgetsBinding.instance.addPostFrameCallback((_) {
// needs to be delayed so that EasyLocalization is working
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
if (Store.isBetaTimelineEnabled) {
ref.read(driftBackgroundUploadFgService).enableSyncService();
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
ref.read(backgroundServiceProvider).disableService();
ref.read(driftBackgroundUploadFgService).enableUploadService();
}
} else {
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.read(driftBackgroundUploadFgService).disableUploadService();
}
});
ref.read(shareIntentUploadProvider.notifier).init();

View file

@ -8,6 +8,7 @@ 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';
@ -42,10 +43,12 @@ 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

@ -13,7 +13,9 @@ import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
@ -68,12 +70,16 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await ref.read(backgroundServiceProvider).disableService();
}
} else {
await ref.read(backgroundSyncProvider).cancel();
ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
ref.read(websocketProvider.notifier).startListeningToOldEvents();
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 IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);

View file

@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@ -54,6 +55,7 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
@override
Widget build(BuildContext context) {
final isScreenLandscape = context.orientation == Orientation.landscape;
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final navigationDestinations = [
NavigationDestination(
@ -65,16 +67,19 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
label: 'search'.tr(),
icon: const Icon(Icons.search_rounded),
selectedIcon: Icon(Icons.search, color: context.primaryColor),
enabled: !isReadonlyModeEnabled,
),
NavigationDestination(
label: 'albums'.tr(),
icon: const Icon(Icons.photo_album_outlined),
selectedIcon: Icon(Icons.photo_album_rounded, color: context.primaryColor),
enabled: !isReadonlyModeEnabled,
),
NavigationDestination(
label: 'library'.tr(),
icon: const Icon(Icons.space_dashboard_outlined),
selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor),
enabled: !isReadonlyModeEnabled,
),
];

View file

@ -0,0 +1,296 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
}
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
if (empty) {
return <Object?>[];
}
if (error == null) {
return <Object?>[result];
}
return <Object?>[error.code, error.message, error.details];
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class BackgroundWorkerFgHostApi {
/// Constructor for [BackgroundWorkerFgHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
BackgroundWorkerFgHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<void> enableSyncWorker() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$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> enableUploadWorker(int callbackHandle) 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(<Object?>[callbackHandle]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
Future<void> disableUploadWorker() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
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;
}
}
}
class BackgroundWorkerBgHostApi {
/// Constructor for [BackgroundWorkerBgHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
BackgroundWorkerBgHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<void> onInitialized() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
}
abstract class BackgroundWorkerFlutterApi {
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
Future<void> onLocalSync(int? maxSeconds);
Future<void> onIosUpload(bool isRefresh, int? maxSeconds);
Future<void> onAndroidUpload();
Future<void> cancel();
static void setUp(
BackgroundWorkerFlutterApi? api, {
BinaryMessenger? binaryMessenger,
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',
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.onIosUpload was null.',
);
final List<Object?> args = (message as List<Object?>?)!;
final bool? arg_isRefresh = (args[0] as bool?);
assert(
arg_isRefresh != null,
'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload was null, expected non-null bool.',
);
final int? arg_maxSeconds = (args[1] as int?);
try {
await api.onIosUpload(arg_isRefresh!, 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.onAndroidUpload$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
try {
await api.onAndroidUpload();
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.cancel$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
try {
await api.cancel();
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
}
}

View file

@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class MainTimelinePage extends ConsumerWidget {
@ -12,25 +11,10 @@ class MainTimelinePage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
final memoriesEnabled = ref.watch(currentUserProvider.select((user) => user?.memoryEnabled ?? true));
// TODO: the user preferences need to be updated
// from the server to get live hiding/showing of memory lane
return memoryLaneProvider.maybeWhen(
data: (memories) {
return memories.isEmpty || !memoriesEnabled
? const Timeline()
: Timeline(
topSliverWidget: SliverToBoxAdapter(
key: Key('memory-lane-${memories.first.assets.first.id}'),
child: DriftMemoryLane(memories: memories),
),
topSliverWidgetHeight: 200,
);
},
orElse: () => const Timeline(),
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
topSliverWidgetHeight: hasMemories ? 200 : 0,
);
}
}

View file

@ -24,6 +24,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@ -308,7 +309,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height);
}
if (distanceToOrigin > openThreshold && !showingBottomSheet) {
if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) {
_openBottomSheet(ctx);
}
}

View file

@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_acti
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
@ -26,6 +27,7 @@ class ViewerBottomBar extends ConsumerWidget {
return const SizedBox.shrink();
}
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
@ -60,7 +62,7 @@ class ViewerBottomBar extends ConsumerWidget {
duration: Durations.short2,
child: AnimatedSwitcher(
duration: Durations.short4,
child: isSheetOpen
child: isSheetOpen || isReadonlyModeEnabled
? const SizedBox.shrink()
: Theme(
data: context.themeData.copyWith(

View file

@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -34,6 +35,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final previousRouteName = ref.watch(previousRouteNameProvider);
final showViewInTimelineButton =
@ -94,7 +96,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: isShowingSheet
actions: isShowingSheet || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions

View file

@ -87,9 +87,10 @@ class NativeVideoViewer extends HookConsumerWidget {
return null;
}
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
try {
if (asset.hasLocal && asset.livePhotoVideoId == null) {
final id = asset is LocalAsset ? (asset as LocalAsset).id : (asset as RemoteAsset).localId!;
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await const StorageRepository().getFileForAsset(id);
if (file == null) {
throw Exception('No file found for the video');
@ -99,14 +100,14 @@ class NativeVideoViewer extends HookConsumerWidget {
return source;
}
final remoteId = (asset as RemoteAsset).id;
final remoteId = (videoAsset as RemoteAsset).id;
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/$postfixUrl'
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
final source = await VideoSource.init(
@ -116,7 +117,7 @@ class NativeVideoViewer extends HookConsumerWidget {
);
return source;
} catch (error) {
log.severe('Error creating video source for asset ${asset.name}: $error');
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
return null;
}
}

View file

@ -50,12 +50,11 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
if (isCancelled) {
this.request = null;
evict();
return;
}
this.request = request;
try {
final image = await request.load(decode);
if (image == null || isCancelled) {

View file

@ -35,7 +35,8 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
}
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
return loadRequest(LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType), decode);
final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType);
return loadRequest(request, decode);
}
@override
@ -87,7 +88,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
}
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final request = LocalImageRequest(
final request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,

View file

@ -36,7 +36,7 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
}
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = RemoteImageRequest(
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId),
headers: ApiService.getRequestHeaders(),
cacheManager: cacheManager,
@ -92,16 +92,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
}
final headers = ApiService.getRequestHeaders();
try {
final request = RemoteImageRequest(
uri: getPreviewUrlForRemoteId(key.assetId),
headers: headers,
cacheManager: cacheManager,
);
yield* loadRequest(request, decode);
} finally {
request = null;
}
final request = this.request = RemoteImageRequest(
uri: getPreviewUrlForRemoteId(key.assetId),
headers: headers,
cacheManager: cacheManager,
);
yield* loadRequest(request, decode);
if (isCancelled) {
evict();
@ -109,12 +105,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
}
if (AppSetting.get(Setting.loadOriginal)) {
try {
final request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
yield* loadRequest(request, decode);
} finally {
request = null;
}
final request = this.request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
yield* loadRequest(request, decode);
}
}

View file

@ -17,11 +17,12 @@ class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
@override
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode))..addOnLastListenerRemovedCallback(cancel);
return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onDispose: cancel);
}
Stream<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) {
return loadRequest(ThumbhashImageRequest(thumbhash: key.thumbHash), decode);
final request = this.request = ThumbhashImageRequest(thumbhash: key.thumbHash);
return loadRequest(request, decode);
}
@override

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
@ -94,7 +95,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
imageInfo.dispose();
return;
}
_fadeController.value = 1.0;
setState(() {
_providerImage = imageInfo.image;
});
@ -115,7 +116,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
final imageStreamListener = _imageStreamListener = ImageStreamListener(
(ImageInfo imageInfo, bool synchronousCall) {
_stopListeningToStream();
_stopListeningToThumbhashStream();
if (!mounted) {
imageInfo.dispose();
return;
@ -125,7 +126,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
return;
}
if (synchronousCall && _providerImage == null) {
if ((synchronousCall && _providerImage == null) || !_isVisible()) {
_fadeController.value = 1.0;
} else if (_fadeController.isAnimating) {
_fadeController.forward();
@ -201,6 +202,15 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
_loadFromThumbhashProvider();
}
bool _isVisible() {
final renderObject = context.findRenderObject() as RenderBox?;
if (renderObject == null || !renderObject.attached) return false;
final topLeft = renderObject.localToGlobal(Offset.zero);
final bottomRight = renderObject.localToGlobal(Offset(renderObject.size.width, renderObject.size.height));
return topLeft.dy < context.height && bottomRight.dy > 0;
}
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
@ -226,6 +236,16 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
@override
void dispose() {
final imageProvider = widget.imageProvider;
if (imageProvider is CancellableImageProvider) {
imageProvider.cancel();
}
final thumbhashProvider = widget.thumbhashProvider;
if (thumbhashProvider is CancellableImageProvider) {
thumbhashProvider.cancel();
}
_fadeController.removeStatusListener(_onAnimationStatusChanged);
_fadeController.dispose();
_stopListeningToStream();

View file

@ -7,15 +7,20 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class DriftMemoryLane extends ConsumerWidget {
final List<DriftMemory> memories;
const DriftMemoryLane({super.key, required this.memories});
const DriftMemoryLane({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
final memories = memoryLaneProvider.value ?? const [];
if (memories.isEmpty) {
return const SizedBox.shrink();
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: CarouselView(
@ -38,7 +43,9 @@ class DriftMemoryLane extends ConsumerWidget {
context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index));
},
children: memories.map((memory) => DriftMemoryCard(memory: memory)).toList(),
children: memories
.map((memory) => DriftMemoryCard(key: Key(memory.id), memory: memory))
.toList(growable: false),
),
);
}

View file

@ -15,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@ -190,11 +191,12 @@ class _AssetTileWidget extends ConsumerWidget {
final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
return RepaintBoundary(
child: GestureDetector(
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
onLongPress: () => lockSelection ? null : _handleOnLongPress(ref, asset),
onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset),
child: ThumbnailTile(
asset,
lockSelection: lockSelection,

View file

@ -7,9 +7,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TimelineHeader extends StatelessWidget {
class TimelineHeader extends HookConsumerWidget {
final Bucket bucket;
final HeaderType header;
final double height;
@ -36,13 +37,12 @@ class TimelineHeader extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
if (bucket is! TimeBucket || header == HeaderType.none) {
return const SizedBox.shrink();
}
final date = (bucket as TimeBucket).date;
final isMonthHeader = header == HeaderType.month || header == HeaderType.monthAndDay;
final isDayHeader = header == HeaderType.day || header == HeaderType.monthAndDay;
@ -57,7 +57,10 @@ class TimelineHeader extends StatelessWidget {
if (isMonthHeader)
Row(
children: [
Text(_formatMonth(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 24)),
Text(
toBeginningOfSentenceCase(_formatMonth(context, date)),
style: context.textTheme.labelLarge?.copyWith(fontSize: 24),
),
const Spacer(),
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
],
@ -65,7 +68,10 @@ class TimelineHeader extends StatelessWidget {
if (isDayHeader)
Row(
children: [
Text(_formatDay(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 15)),
Text(
toBeginningOfSentenceCase(_formatDay(context, date)),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
const Spacer(),
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
],
@ -92,16 +98,19 @@ class _BulkSelectIconButton extends ConsumerWidget {
bucketAssets = <BaseAsset>[];
}
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isAllSelected = ref.watch(bucketSelectionProvider(bucketAssets));
return IconButton(
onPressed: () {
ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount);
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
},
icon: isAllSelected
? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor)
: Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary),
);
return isReadonlyModeEnabled
? const SizedBox.shrink()
: IconButton(
onPressed: () {
ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount);
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
},
icon: isAllSelected
? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor)
: Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary),
);
}
}

View file

@ -19,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@ -256,6 +257,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
return PopScope(
canPop: !isMultiSelectEnabled,
@ -342,9 +344,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
),
},
child: TimelineDragRegion(
onStart: _setDragStartIndex,
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
onAssetEnter: _handleDragAssetEnter,
onEnd: _stopDrag,
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts

View file

@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@ -17,6 +18,7 @@ import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
@ -34,6 +36,8 @@ import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final driftBackgroundUploadFgService = Provider((ref) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(
ref.watch(backupServiceProvider),

View file

@ -38,9 +38,21 @@ abstract class RemoteCacheManager extends CacheManager {
final file = await store.fileSystem.createFile(path);
final sink = file.openWrite();
try {
await source.pipe(sink);
await source.listen(sink.add, cancelOnError: true).asFuture();
} catch (e) {
try {
await sink.close();
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
try {
await sink.flush();
await sink.close();
} catch (e) {
try {
await file.delete();
} catch (e) {

View file

@ -14,13 +14,12 @@ final driftMemoryServiceProvider = Provider<DriftMemoryService>(
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
);
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) async {
final user = ref.watch(currentUserProvider);
if (user == null) {
return [];
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
if (userId == null || !enabled) {
return const [];
}
final service = ref.watch(driftMemoryServiceProvider);
return service.getMemoryLane(user.id);
return service.getMemoryLane(userId);
});

View file

@ -0,0 +1,36 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
class ReadOnlyModeNotifier extends Notifier<bool> {
late AppSettingsService _appSettingService;
@override
bool build() {
_appSettingService = ref.read(appSettingsServiceProvider);
final readonlyMode = _appSettingService.getSetting(AppSettingsEnum.readonlyModeEnabled);
return readonlyMode;
}
void setMode(bool value) {
_appSettingService.setSetting(AppSettingsEnum.readonlyModeEnabled, value);
state = value;
if (value) {
ref.read(appRouterProvider).navigate(const MainTimelineRoute());
}
}
void setReadonlyMode(bool isEnabled) {
state = isEnabled;
setMode(state);
}
void toggleReadonlyMode() {
state = !state;
setMode(state);
}
}
final readonlyModeProvider = NotifierProvider<ReadOnlyModeNotifier, bool>(() => ReadOnlyModeNotifier());

View file

@ -27,8 +27,12 @@ class UploadRepository {
);
}
void enqueueBackgroundAll(List<UploadTask> tasks) {
FileDownloader().enqueueAll(tasks);
Future<void> enqueueBackground(UploadTask task) {
return FileDownloader().enqueue(task);
}
Future<void> enqueueBackgroundAll(List<UploadTask> tasks) {
return FileDownloader().enqueueAll(tasks);
}
Future<void> deleteDatabaseRecords(String group) {

View file

@ -49,7 +49,8 @@ enum AppSettingsEnum<T> {
betaTimeline<bool>(StoreKey.betaTimeline, null, false),
enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false);
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View file

@ -78,8 +78,8 @@ class UploadService {
_taskProgressController.close();
}
void enqueueTasks(List<UploadTask> tasks) {
_uploadRepository.enqueueBackgroundAll(tasks);
Future<void> enqueueTasks(List<UploadTask> tasks) {
return _uploadRepository.enqueueBackgroundAll(tasks);
}
Future<List<Task>> getActiveTasks(String group) {
@ -113,7 +113,7 @@ class UploadService {
}
if (tasks.isNotEmpty) {
enqueueTasks(tasks);
await enqueueTasks(tasks);
}
}
@ -149,13 +149,37 @@ class UploadService {
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
count += tasks.length;
enqueueTasks(tasks);
await enqueueTasks(tasks);
onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length));
}
}
}
// Enqueue All does not work from the background on Android yet. This method is a temporary workaround
// that enqueues tasks one by one.
Future<void> startBackupSerial(String userId) async {
await _storageRepository.clearCache();
shouldAbortQueuingTasks = false;
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
return;
}
for (final asset in candidates) {
if (shouldAbortQueuingTasks) {
break;
}
final task = await _getUploadTask(asset);
if (task != null) {
await _uploadRepository.enqueueBackground(task);
}
}
}
/// Cancel all ongoing uploads and reset the upload queue
///
/// Return the number of left over tasks in the queue

View file

@ -1,6 +1,8 @@
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@ -11,6 +13,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
@ -22,6 +25,36 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
void configureFileDownloaderNotifications() {
FileDownloader().configureNotificationForGroup(
kDownloadGroupImage,
running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'),
complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kDownloadGroupVideo,
running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'),
complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kManualUploadGroup,
running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()),
complete: TaskNotification('upload_finished'.t(), 'backup_background_service_in_progress_notification'.t()),
groupNotificationId: kManualUploadGroup,
);
FileDownloader().configureNotificationForGroup(
kBackupGroup,
running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()),
complete: TaskNotification('upload_finished'.t(), 'backup_background_service_in_progress_notification'.t()),
groupNotificationId: kBackupGroup,
);
}
abstract final class Bootstrap {
static Future<(Isar isar, Drift drift, DriftLogger logDb)> initDB() async {
final drift = Drift();

View file

@ -57,7 +57,7 @@ Cancelable<T?> runInIsolateGentle<T>({
log.severe("Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}", error, stack);
} finally {
try {
await LogService.I.flush();
await LogService.I.dispose();
await logDb.close();
await ref.read(driftProvider).close();
@ -72,8 +72,8 @@ Cancelable<T?> runInIsolateGentle<T>({
}
ref.dispose();
} catch (error) {
debugPrint("Error closing resources in isolate: $error");
} catch (error, stack) {
debugPrint("Error closing resources in isolate: $error, $stack");
} finally {
ref.dispose();
// Delay to ensure all resources are released

View file

@ -28,6 +28,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
case 'AssetResponseDto':
if (value is Map) {
addDefault(value, 'visibility', 'timeline');
addDefault(value, 'createdAt', DateTime.now().toIso8601String());
}
break;
case 'UserAdminResponseDto':

View file

@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart';
@ -33,6 +34,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
final user = ref.watch(currentUserProvider);
final isLoggingOut = useState(false);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
useEffect(() {
ref.read(backupProvider.notifier).updateDiskInfo();
@ -214,6 +216,25 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
buildReadonlyMessage() {
return Padding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0),
child: ListTile(
dense: true,
visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 20, right: 20),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
minLeadingWidth: 20,
tileColor: theme.primaryColor.withAlpha(80),
title: Text(
"profile_drawer_readonly_mode",
style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)),
textAlign: TextAlign.center,
).tr(),
),
);
}
return Dismissible(
behavior: HitTestBehavior.translucent,
direction: DismissDirection.down,
@ -238,6 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
const AppBarProfileInfoBox(),
buildStorageInformation(),
const AppBarServerInfo(),
if (isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildSettingButton(),
buildSignOutButton(),

View file

@ -1,9 +1,12 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -17,6 +20,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status;
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final user = ref.watch(currentUserProvider);
buildUserProfileImage() {
@ -55,6 +59,25 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
}
}
void toggleReadonlyMode() {
// read only mode is only supported int he beta experience
// TODO: remove this check when the beta UI goes stable
if (!Store.isBetaTimelineEnabled) return;
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
(isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Container(
@ -67,23 +90,25 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
minLeadingWidth: 50,
leading: GestureDetector(
onTap: pickUserProfileImage,
onDoubleTap: toggleReadonlyMode,
child: Stack(
clipBehavior: Clip.none,
children: [
buildUserProfileImage(),
Positioned(
bottom: -5,
right: -8,
child: Material(
color: context.colorScheme.surfaceContainerHighest,
elevation: 3,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14),
if (!isReadonlyModeEnabled)
Positioned(
bottom: -5,
right: -8,
child: Material(
color: context.colorScheme.surfaceContainerHighest,
elevation: 3,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14),
),
),
),
),
],
),
),

View file

@ -10,6 +10,7 @@ import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@ -42,6 +43,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return SliverAnimatedOpacity(
@ -57,7 +59,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
centerTitle: false,
title: title ?? const _ImmichLogoWithText(),
actions: [
if (isCasting)
if (isCasting && !isReadonlyModeEnabled)
Padding(
padding: const EdgeInsets.only(right: 12),
child: IconButton(
@ -70,12 +72,13 @@ class ImmichSliverAppBar extends ConsumerWidget {
const _SyncStatusIndicator(),
if (actions != null)
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if (kDebugMode || kProfileMode)
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
IconButton(
icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()),
),
if (showUploadButton) const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),
if (showUploadButton && !isReadonlyModeEnabled)
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),
const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()),
],
),
@ -137,8 +140,24 @@ class _ProfileIndicator extends ConsumerWidget {
final user = ref.watch(currentUserProvider);
const widgetSize = 30.0;
void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
(isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
}
return InkWell(
onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
onDoubleTap: () => toggleReadonlyMode(),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
label: Container(

View file

@ -272,9 +272,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
void initState() {
super.initState();
_zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this);
_zoomController = AnimationController(
duration: const Duration(seconds: 12),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this);
_crossFadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_zoomAnimation = Tween<double>(
begin: 1.0,

View file

@ -378,9 +378,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
void initState() {
super.initState();
_zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this);
_zoomController = AnimationController(
duration: const Duration(seconds: 12),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this);
_crossFadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_zoomAnimation = Tween<double>(
begin: 1.0,

View file

@ -378,9 +378,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
void initState() {
super.initState();
_zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this);
_zoomController = AnimationController(
duration: const Duration(seconds: 12),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this);
_crossFadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_zoomAnimation = Tween<double>(
begin: 1.0,

View file

@ -48,7 +48,7 @@ class UserCircleAvatar extends ConsumerWidget {
borderRadius: const BorderRadius.all(Radius.circular(50)),
child: CachedNetworkImage(
fit: BoxFit.cover,
cacheKey: user.profileChangedAt.toIso8601String(),
cacheKey: '${user.id}-${user.profileChangedAt.toIso8601String()}',
width: size,
height: size,
placeholder: (_, __) => Image.memory(kTransparentImage),

View file

@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
@ -277,7 +276,6 @@ class LoginForm extends HookConsumerWidget {
}
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
await runNewSync(ref);
context.replaceRoute(const TabShellRoute());
return;
}

View file

@ -172,12 +172,36 @@ class _ImageWrapperState extends State<ImageWrapper> {
@override
Widget build(BuildContext context) {
if (_loading) {
return _buildLoading(context);
}
if (_lastException != null) {
return _buildError(context);
if (_loading || _lastException != null) {
return CustomChildWrapper(
childSize: null,
backgroundDecoration: widget.backgroundDecoration,
heroAttributes: widget.heroAttributes,
scaleStateChangedCallback: widget.scaleStateChangedCallback,
enableRotation: widget.enableRotation,
controller: widget.controller,
scaleStateController: widget.scaleStateController,
maxScale: widget.maxScale,
minScale: widget.minScale,
initialScale: widget.initialScale,
basePosition: widget.basePosition,
scaleStateCycle: widget.scaleStateCycle,
onTapUp: widget.onTapUp,
onTapDown: widget.onTapDown,
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart,
outerSize: widget.outerSize,
gestureDetectorBehavior: widget.gestureDetectorBehavior,
tightMode: widget.tightMode,
filterQuality: widget.filterQuality,
disableGestures: widget.disableGestures,
disableScaleGestures: true,
enablePanAlways: widget.enablePanAlways,
child: _loading ? _buildLoading(context) : _buildError(context),
);
}
final scaleBoundaries = ScaleBoundaries(

View file

@ -6,7 +6,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@ -31,6 +34,7 @@ class AdvancedSettings extends HookConsumerWidget {
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
final logLevel = Level.LEVELS[levelId.value].name;
@ -102,6 +106,26 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_enable_alternate_media_filter_title".tr(),
subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(),
),
// TODO: Remove this check when beta timeline goes stable
if (Store.isBetaTimelineEnabled)
SettingsSwitchListTile(
valueNotifier: readonlyModeEnabled,
title: "advanced_settings_readonly_mode_title".tr(),
subtitle: "advanced_settings_readonly_mode_subtitle".tr(),
onChanged: (value) {
readonlyModeEnabled.value = value;
ref.read(readonlyModeProvider.notifier).setReadonlyMode(value);
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
(value ? "readonly_mode_enabled" : "readonly_mode_disabled").tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
},
),
];
return SettingsSubPageScaffold(settings: advancedSettings);

View file

@ -8,8 +8,10 @@ build:
pigeon:
dart run pigeon --input pigeon/native_sync_api.dart
dart run pigeon --input pigeon/thumbnail_api.dart
dart run pigeon --input pigeon/background_worker_api.dart
dart format lib/platform/native_sync_api.g.dart
dart format lib/platform/thumbnail_api.g.dart
dart format lib/platform/background_worker_api.g.dart
watch:
dart run build_runner watch --delete-conflicting-outputs

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.139.2
- API version: 1.140.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@ -97,16 +97,20 @@ Class | Method | HTTP request | Description
*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} |
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | checkBulkUpload
*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | checkExistingAssets
*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} |
*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets |
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original |
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} |
*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata |
*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} |
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics |
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs |
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} |
*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata |
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
@ -328,6 +332,10 @@ Class | Method | HTTP request | Description
- [AssetMediaResponseDto](doc//AssetMediaResponseDto.md)
- [AssetMediaSize](doc//AssetMediaSize.md)
- [AssetMediaStatus](doc//AssetMediaStatus.md)
- [AssetMetadataKey](doc//AssetMetadataKey.md)
- [AssetMetadataResponseDto](doc//AssetMetadataResponseDto.md)
- [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md)
- [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md)
- [AssetOrder](doc//AssetOrder.md)
- [AssetResponseDto](doc//AssetResponseDto.md)
- [AssetStackResponseDto](doc//AssetStackResponseDto.md)
@ -485,6 +493,8 @@ Class | Method | HTTP request | Description
- [SyncAssetExifV1](doc//SyncAssetExifV1.md)
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)
- [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md)
- [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md)
- [SyncAssetV1](doc//SyncAssetV1.md)
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
- [SyncEntityType](doc//SyncEntityType.md)

View file

@ -106,6 +106,10 @@ part 'model/asset_jobs_dto.dart';
part 'model/asset_media_response_dto.dart';
part 'model/asset_media_size.dart';
part 'model/asset_media_status.dart';
part 'model/asset_metadata_key.dart';
part 'model/asset_metadata_response_dto.dart';
part 'model/asset_metadata_upsert_dto.dart';
part 'model/asset_metadata_upsert_item_dto.dart';
part 'model/asset_order.dart';
part 'model/asset_response_dto.dart';
part 'model/asset_stack_response_dto.dart';
@ -263,6 +267,8 @@ part 'model/sync_asset_delete_v1.dart';
part 'model/sync_asset_exif_v1.dart';
part 'model/sync_asset_face_delete_v1.dart';
part 'model/sync_asset_face_v1.dart';
part 'model/sync_asset_metadata_delete_v1.dart';
part 'model/sync_asset_metadata_v1.dart';
part 'model/sync_asset_v1.dart';
part 'model/sync_auth_user_v1.dart';
part 'model/sync_entity_type.dart';

View file

@ -128,6 +128,56 @@ class AssetsApi {
return null;
}
/// This endpoint requires the `asset.update` permission.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetMetadataKey] key (required):
Future<Response> deleteAssetMetadataWithHttpInfo(String id, AssetMetadataKey key,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/metadata/{key}'
.replaceAll('{id}', id)
.replaceAll('{key}', key.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This endpoint requires the `asset.update` permission.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetMetadataKey] key (required):
Future<void> deleteAssetMetadata(String id, AssetMetadataKey key,) async {
final response = await deleteAssetMetadataWithHttpInfo(id, key,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// This endpoint requires the `asset.delete` permission.
///
/// Note: This method returns the HTTP [Response].
@ -368,6 +418,120 @@ class AssetsApi {
return null;
}
/// This endpoint requires the `asset.read` permission.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> getAssetMetadataWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/metadata'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This endpoint requires the `asset.read` permission.
///
/// Parameters:
///
/// * [String] id (required):
Future<List<AssetMetadataResponseDto>?> getAssetMetadata(String id,) async {
final response = await getAssetMetadataWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AssetMetadataResponseDto>') as List)
.cast<AssetMetadataResponseDto>()
.toList(growable: false);
}
return null;
}
/// This endpoint requires the `asset.read` permission.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetMetadataKey] key (required):
Future<Response> getAssetMetadataByKeyWithHttpInfo(String id, AssetMetadataKey key,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/metadata/{key}'
.replaceAll('{id}', id)
.replaceAll('{key}', key.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This endpoint requires the `asset.read` permission.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetMetadataKey] key (required):
Future<AssetMetadataResponseDto?> getAssetMetadataByKey(String id, AssetMetadataKey key,) async {
final response = await getAssetMetadataByKeyWithHttpInfo(id, key,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMetadataResponseDto',) as AssetMetadataResponseDto;
}
return null;
}
/// This endpoint requires the `asset.statistics` permission.
///
/// Note: This method returns the HTTP [Response].
@ -795,6 +959,66 @@ class AssetsApi {
return null;
}
/// This endpoint requires the `asset.update` permission.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetMetadataUpsertDto] assetMetadataUpsertDto (required):
Future<Response> updateAssetMetadataWithHttpInfo(String id, AssetMetadataUpsertDto assetMetadataUpsertDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/metadata'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = assetMetadataUpsertDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This endpoint requires the `asset.update` permission.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetMetadataUpsertDto] assetMetadataUpsertDto (required):
Future<List<AssetMetadataResponseDto>?> updateAssetMetadata(String id, AssetMetadataUpsertDto assetMetadataUpsertDto,) async {
final response = await updateAssetMetadataWithHttpInfo(id, assetMetadataUpsertDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AssetMetadataResponseDto>') as List)
.cast<AssetMetadataResponseDto>()
.toList(growable: false);
}
return null;
}
/// This endpoint requires the `asset.update` permission.
///
/// Note: This method returns the HTTP [Response].
@ -855,6 +1079,8 @@ class AssetsApi {
///
/// * [DateTime] fileModifiedAt (required):
///
/// * [List<AssetMetadataUpsertItemDto>] metadata (required):
///
/// * [String] key:
///
/// * [String] slug:
@ -873,7 +1099,7 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
///
/// * [AssetVisibility] visibility:
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List<AssetMetadataUpsertItemDto> metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets';
@ -936,6 +1162,10 @@ class AssetsApi {
hasFields = true;
mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId);
}
if (metadata != null) {
hasFields = true;
mp.fields[r'metadata'] = parameterToString(metadata);
}
if (sidecarData != null) {
hasFields = true;
mp.fields[r'sidecarData'] = sidecarData.field;
@ -974,6 +1204,8 @@ class AssetsApi {
///
/// * [DateTime] fileModifiedAt (required):
///
/// * [List<AssetMetadataUpsertItemDto>] metadata (required):
///
/// * [String] key:
///
/// * [String] slug:
@ -992,8 +1224,8 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
///
/// * [AssetVisibility] visibility:
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, );
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List<AssetMetadataUpsertItemDto> metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, metadata, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View file

@ -266,6 +266,14 @@ class ApiClient {
return AssetMediaSizeTypeTransformer().decode(value);
case 'AssetMediaStatus':
return AssetMediaStatusTypeTransformer().decode(value);
case 'AssetMetadataKey':
return AssetMetadataKeyTypeTransformer().decode(value);
case 'AssetMetadataResponseDto':
return AssetMetadataResponseDto.fromJson(value);
case 'AssetMetadataUpsertDto':
return AssetMetadataUpsertDto.fromJson(value);
case 'AssetMetadataUpsertItemDto':
return AssetMetadataUpsertItemDto.fromJson(value);
case 'AssetOrder':
return AssetOrderTypeTransformer().decode(value);
case 'AssetResponseDto':
@ -580,6 +588,10 @@ class ApiClient {
return SyncAssetFaceDeleteV1.fromJson(value);
case 'SyncAssetFaceV1':
return SyncAssetFaceV1.fromJson(value);
case 'SyncAssetMetadataDeleteV1':
return SyncAssetMetadataDeleteV1.fromJson(value);
case 'SyncAssetMetadataV1':
return SyncAssetMetadataV1.fromJson(value);
case 'SyncAssetV1':
return SyncAssetV1.fromJson(value);
case 'SyncAuthUserV1':

View file

@ -67,6 +67,9 @@ String parameterToString(dynamic value) {
if (value is AssetMediaStatus) {
return AssetMediaStatusTypeTransformer().encode(value).toString();
}
if (value is AssetMetadataKey) {
return AssetMetadataKeyTypeTransformer().encode(value).toString();
}
if (value is AssetOrder) {
return AssetOrderTypeTransformer().encode(value).toString();
}

View file

@ -13,16 +13,10 @@ part of openapi.api;
class AlbumsAddAssetsResponseDto {
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
AlbumsAddAssetsResponseDto({
required this.albumSuccessCount,
required this.assetSuccessCount,
this.error,
required this.success,
});
int albumSuccessCount;
int assetSuccessCount;
///
/// 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
@ -35,26 +29,20 @@ class AlbumsAddAssetsResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
other.albumSuccessCount == albumSuccessCount &&
other.assetSuccessCount == assetSuccessCount &&
other.error == error &&
other.success == success;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumSuccessCount.hashCode) +
(assetSuccessCount.hashCode) +
(error == null ? 0 : error!.hashCode) +
(success.hashCode);
@override
String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]';
String toString() => 'AlbumsAddAssetsResponseDto[error=$error, success=$success]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumSuccessCount'] = this.albumSuccessCount;
json[r'assetSuccessCount'] = this.assetSuccessCount;
if (this.error != null) {
json[r'error'] = this.error;
} else {
@ -73,8 +61,6 @@ class AlbumsAddAssetsResponseDto {
final json = value.cast<String, dynamic>();
return AlbumsAddAssetsResponseDto(
albumSuccessCount: mapValueOfType<int>(json, r'albumSuccessCount')!,
assetSuccessCount: mapValueOfType<int>(json, r'assetSuccessCount')!,
error: BulkIdErrorReason.fromJson(json[r'error']),
success: mapValueOfType<bool>(json, r'success')!,
);
@ -124,8 +110,6 @@ class AlbumsAddAssetsResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'albumSuccessCount',
'assetSuccessCount',
'success',
};
}

View file

@ -0,0 +1,82 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataKey {
/// Instantiate a new enum with the provided [value].
const AssetMetadataKey._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const mobileApp = AssetMetadataKey._(r'mobile-app');
/// List of all possible values in this [enum][AssetMetadataKey].
static const values = <AssetMetadataKey>[
mobileApp,
];
static AssetMetadataKey? fromJson(dynamic value) => AssetMetadataKeyTypeTransformer().decode(value);
static List<AssetMetadataKey> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataKey>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataKey.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [AssetMetadataKey] to String,
/// and [decode] dynamic data back to [AssetMetadataKey].
class AssetMetadataKeyTypeTransformer {
factory AssetMetadataKeyTypeTransformer() => _instance ??= const AssetMetadataKeyTypeTransformer._();
const AssetMetadataKeyTypeTransformer._();
String encode(AssetMetadataKey data) => data.value;
/// Decodes a [dynamic value][data] to a AssetMetadataKey.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetMetadataKey? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'mobile-app': return AssetMetadataKey.mobileApp;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [AssetMetadataKeyTypeTransformer] instance.
static AssetMetadataKeyTypeTransformer? _instance;
}

View file

@ -0,0 +1,115 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataResponseDto {
/// Returns a new [AssetMetadataResponseDto] instance.
AssetMetadataResponseDto({
required this.key,
required this.updatedAt,
required this.value,
});
AssetMetadataKey key;
DateTime updatedAt;
Object value;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetMetadataResponseDto &&
other.key == key &&
other.updatedAt == updatedAt &&
other.value == value;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(key.hashCode) +
(updatedAt.hashCode) +
(value.hashCode);
@override
String toString() => 'AssetMetadataResponseDto[key=$key, updatedAt=$updatedAt, value=$value]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'key'] = this.key;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'value'] = this.value;
return json;
}
/// Returns a new [AssetMetadataResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetMetadataResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetMetadataResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetMetadataResponseDto(
key: AssetMetadataKey.fromJson(json[r'key'])!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
value: mapValueOfType<Object>(json, r'value')!,
);
}
return null;
}
static List<AssetMetadataResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetMetadataResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetMetadataResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetMetadataResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetMetadataResponseDto-objects as value to a dart map
static Map<String, List<AssetMetadataResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetMetadataResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetMetadataResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'key',
'updatedAt',
'value',
};
}

View file

@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataUpsertDto {
/// Returns a new [AssetMetadataUpsertDto] instance.
AssetMetadataUpsertDto({
this.items = const [],
});
List<AssetMetadataUpsertItemDto> items;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetMetadataUpsertDto &&
_deepEquality.equals(other.items, items);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(items.hashCode);
@override
String toString() => 'AssetMetadataUpsertDto[items=$items]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'items'] = this.items;
return json;
}
/// Returns a new [AssetMetadataUpsertDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetMetadataUpsertDto? fromJson(dynamic value) {
upgradeDto(value, "AssetMetadataUpsertDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetMetadataUpsertDto(
items: AssetMetadataUpsertItemDto.listFromJson(json[r'items']),
);
}
return null;
}
static List<AssetMetadataUpsertDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataUpsertDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataUpsertDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetMetadataUpsertDto> mapFromJson(dynamic json) {
final map = <String, AssetMetadataUpsertDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetMetadataUpsertDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetMetadataUpsertDto-objects as value to a dart map
static Map<String, List<AssetMetadataUpsertDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetMetadataUpsertDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetMetadataUpsertDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'items',
};
}

View file

@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataUpsertItemDto {
/// Returns a new [AssetMetadataUpsertItemDto] instance.
AssetMetadataUpsertItemDto({
required this.key,
required this.value,
});
AssetMetadataKey key;
Object value;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetMetadataUpsertItemDto &&
other.key == key &&
other.value == value;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(key.hashCode) +
(value.hashCode);
@override
String toString() => 'AssetMetadataUpsertItemDto[key=$key, value=$value]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'key'] = this.key;
json[r'value'] = this.value;
return json;
}
/// Returns a new [AssetMetadataUpsertItemDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetMetadataUpsertItemDto? fromJson(dynamic value) {
upgradeDto(value, "AssetMetadataUpsertItemDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetMetadataUpsertItemDto(
key: AssetMetadataKey.fromJson(json[r'key'])!,
value: mapValueOfType<Object>(json, r'value')!,
);
}
return null;
}
static List<AssetMetadataUpsertItemDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataUpsertItemDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataUpsertItemDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetMetadataUpsertItemDto> mapFromJson(dynamic json) {
final map = <String, AssetMetadataUpsertItemDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetMetadataUpsertItemDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetMetadataUpsertItemDto-objects as value to a dart map
static Map<String, List<AssetMetadataUpsertItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetMetadataUpsertItemDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetMetadataUpsertItemDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'key',
'value',
};
}

View file

@ -14,6 +14,7 @@ class AssetResponseDto {
/// Returns a new [AssetResponseDto] instance.
AssetResponseDto({
required this.checksum,
required this.createdAt,
required this.deviceAssetId,
required this.deviceId,
this.duplicateId,
@ -49,6 +50,9 @@ class AssetResponseDto {
/// base64 encoded sha1 hash
String checksum;
/// The UTC timestamp when the asset was originally uploaded to Immich.
DateTime createdAt;
String deviceAssetId;
String deviceId;
@ -142,6 +146,7 @@ class AssetResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.checksum == checksum &&
other.createdAt == createdAt &&
other.deviceAssetId == deviceAssetId &&
other.deviceId == deviceId &&
other.duplicateId == duplicateId &&
@ -177,6 +182,7 @@ class AssetResponseDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(checksum.hashCode) +
(createdAt.hashCode) +
(deviceAssetId.hashCode) +
(deviceId.hashCode) +
(duplicateId == null ? 0 : duplicateId!.hashCode) +
@ -209,11 +215,12 @@ class AssetResponseDto {
(visibility.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checksum'] = this.checksum;
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'deviceAssetId'] = this.deviceAssetId;
json[r'deviceId'] = this.deviceId;
if (this.duplicateId != null) {
@ -293,6 +300,7 @@ class AssetResponseDto {
return AssetResponseDto(
checksum: mapValueOfType<String>(json, r'checksum')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
deviceId: mapValueOfType<String>(json, r'deviceId')!,
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
@ -371,6 +379,7 @@ class AssetResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checksum',
'createdAt',
'deviceAssetId',
'deviceId',
'duration',

View file

@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAssetMetadataDeleteV1 {
/// Returns a new [SyncAssetMetadataDeleteV1] instance.
SyncAssetMetadataDeleteV1({
required this.assetId,
required this.key,
});
String assetId;
AssetMetadataKey key;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataDeleteV1 &&
other.assetId == assetId &&
other.key == key;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(key.hashCode);
@override
String toString() => 'SyncAssetMetadataDeleteV1[assetId=$assetId, key=$key]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'key'] = this.key;
return json;
}
/// Returns a new [SyncAssetMetadataDeleteV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetMetadataDeleteV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetMetadataDeleteV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetMetadataDeleteV1(
assetId: mapValueOfType<String>(json, r'assetId')!,
key: AssetMetadataKey.fromJson(json[r'key'])!,
);
}
return null;
}
static List<SyncAssetMetadataDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetMetadataDeleteV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetMetadataDeleteV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetMetadataDeleteV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetMetadataDeleteV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetMetadataDeleteV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetMetadataDeleteV1-objects as value to a dart map
static Map<String, List<SyncAssetMetadataDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetMetadataDeleteV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetMetadataDeleteV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'key',
};
}

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