Compare commits
22 Commits
fix/map-th
...
more-user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3af54c6c9 | ||
|
|
f5954f4c9b | ||
|
|
147accd957 | ||
|
|
9487241481 | ||
|
|
460e1d4715 | ||
|
|
b6223af5ca | ||
|
|
8853079c54 | ||
|
|
662d44536e | ||
|
|
80fa5ec198 | ||
|
|
0df88fc22b | ||
|
|
e78144ea31 | ||
|
|
227789225a | ||
|
|
1298a74230 | ||
|
|
a3808c26ce | ||
|
|
e2169f5316 | ||
|
|
f65dabd43a | ||
|
|
a5841a8bf4 | ||
|
|
dc6ac3aaec | ||
|
|
ae104ad7cc | ||
|
|
868d5f56e2 | ||
|
|
88072910da | ||
|
|
25a94bd117 |
@@ -26,7 +26,7 @@ services:
|
|||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
init:
|
init:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
command: sh -c 'for path in /data /data/upload /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'
|
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:
|
immich-machine-learning:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
database:
|
database:
|
||||||
|
|||||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -569,7 +569,8 @@ jobs:
|
|||||||
- name: Build the app
|
- name: Build the app
|
||||||
run: pnpm --filter immich build
|
run: pnpm --filter immich build
|
||||||
- name: Run API generation
|
- name: Run API generation
|
||||||
run: make open-api
|
run: ./bin/generate-open-api.sh
|
||||||
|
working-directory: open-api
|
||||||
- name: Find file changes
|
- name: Find file changes
|
||||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||||
id: verify-changed-files
|
id: verify-changed-files
|
||||||
|
|||||||
38
Makefile
38
Makefile
@@ -60,20 +60,37 @@ VOLUME_DIRS = \
|
|||||||
./e2e/node_modules \
|
./e2e/node_modules \
|
||||||
./docs/node_modules \
|
./docs/node_modules \
|
||||||
./server/node_modules \
|
./server/node_modules \
|
||||||
./server/dist \
|
|
||||||
./open-api/typescript-sdk/node_modules \
|
./open-api/typescript-sdk/node_modules \
|
||||||
./.github/node_modules \
|
./.github/node_modules \
|
||||||
./node_modules \
|
./node_modules \
|
||||||
./cli/node_modules
|
./cli/node_modules
|
||||||
|
|
||||||
# create empty directories and chown to current user
|
# 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:
|
prepare-volumes:
|
||||||
@for dir in $(VOLUME_DIRS); do \
|
@$(foreach dir,$(VOLUME_DIRS),mkdir -p $(dir);)
|
||||||
mkdir -p $$dir; \
|
@$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R))
|
||||||
done
|
ifneq ($(UPLOAD_LOCATION),)
|
||||||
@if [ -n "$(VOLUME_DIRS)" ]; then \
|
ifeq ($(filter /%,$(UPLOAD_LOCATION)),)
|
||||||
chown -R $$(id -u):$$(id -g) $(VOLUME_DIRS); \
|
@mkdir -p "docker/$(UPLOAD_LOCATION)"
|
||||||
fi
|
@$(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
|
MODULES = e2e server web cli sdk docs .github
|
||||||
|
|
||||||
@@ -150,8 +167,9 @@ clean:
|
|||||||
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
|
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
|
||||||
find . -name "coverage" -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 '{}' +
|
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 ./docker/docker-compose.dev.yml down -v --remove-orphans || 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 ./e2e/docker-compose.yml down -v --remove-orphans || true
|
||||||
|
|
||||||
|
|
||||||
setup-server-dev: install-server
|
setup-server-dev: install-server
|
||||||
setup-web-dev: install-sdk build-sdk install-web
|
setup-web-dev: install-sdk build-sdk install-web
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.84",
|
"version": "2.2.85",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
|
|||||||
@@ -185,11 +185,11 @@ services:
|
|||||||
|
|
||||||
init:
|
init:
|
||||||
container_name: init
|
container_name: init
|
||||||
image: busybox
|
image: busybox@sha256:ab33eacc8251e3807b85bb6dba570e4698c3998eca6f0fc2ccb60575a563ea74
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
user: 0:0
|
user: 0:0
|
||||||
command: sh -c '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'
|
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:
|
volumes:
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
- server-node_modules:/usr/src/app/server/node_modules
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Sometimes, an external library will not scan correctly. This can happen if Immic
|
|||||||
- Are the permissions set correctly?
|
- Are the permissions set correctly?
|
||||||
- Make sure you are using forward slashes (`/`) and not backward slashes.
|
- 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
|
### Exclusion Patterns
|
||||||
|
|
||||||
|
|||||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v1.140.0",
|
||||||
|
"url": "https://v1.140.0.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v1.139.4",
|
"label": "v1.139.4",
|
||||||
"url": "https://v1.139.4.archive.immich.app"
|
"url": "https://v1.139.4.archive.immich.app"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.139.4",
|
"version": "1.140.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
20
i18n/en.json
20
i18n/en.json
@@ -396,6 +396,8 @@
|
|||||||
"advanced_settings_prefer_remote_title": "Prefer remote images",
|
"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_subtitle": "Define proxy headers Immich should send with each network request",
|
||||||
"advanced_settings_proxy_headers_title": "Proxy Headers",
|
"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_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_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",
|
"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_bar_signout_dialog_title": "Sign out",
|
||||||
"app_settings": "App Settings",
|
"app_settings": "App Settings",
|
||||||
"appears_in": "Appears in",
|
"appears_in": "Appears in",
|
||||||
|
"apply_count": "Apply ({count, number})",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"archive_action_prompt": "{count} added to Archive",
|
"archive_action_prompt": "{count} added to Archive",
|
||||||
"archive_or_unarchive_photo": "Archive or unarchive photo",
|
"archive_or_unarchive_photo": "Archive or unarchive photo",
|
||||||
@@ -636,6 +639,8 @@
|
|||||||
"cannot_update_the_description": "Cannot update the description",
|
"cannot_update_the_description": "Cannot update the description",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"cast_description": "Configure available cast destinations",
|
"cast_description": "Configure available cast destinations",
|
||||||
|
"cellular_data_for_photos": "Cellular data for photos",
|
||||||
|
"cellular_data_for_videos": "Cellular data for videos",
|
||||||
"change_date": "Change date",
|
"change_date": "Change date",
|
||||||
"change_description": "Change description",
|
"change_description": "Change description",
|
||||||
"change_display_order": "Change display order",
|
"change_display_order": "Change display order",
|
||||||
@@ -1073,12 +1078,18 @@
|
|||||||
"gcast_enabled": "Google Cast",
|
"gcast_enabled": "Google Cast",
|
||||||
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
|
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
|
||||||
"general": "General",
|
"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_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",
|
"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",
|
"getting_started": "Getting Started",
|
||||||
"go_back": "Go back",
|
"go_back": "Go back",
|
||||||
"go_to_folder": "Go to folder",
|
"go_to_folder": "Go to folder",
|
||||||
"go_to_search": "Go to search",
|
"go_to_search": "Go to search",
|
||||||
|
"gps": "GPS",
|
||||||
|
"gps_missing": "No GPS",
|
||||||
"grant_permission": "Grant permission",
|
"grant_permission": "Grant permission",
|
||||||
"group_albums_by": "Group albums by...",
|
"group_albums_by": "Group albums by...",
|
||||||
"group_country": "Group by country",
|
"group_country": "Group by country",
|
||||||
@@ -1262,6 +1273,7 @@
|
|||||||
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
||||||
"main_menu": "Main menu",
|
"main_menu": "Main menu",
|
||||||
"make": "Make",
|
"make": "Make",
|
||||||
|
"manage_geolocation": "Manage location",
|
||||||
"manage_shared_links": "Manage shared links",
|
"manage_shared_links": "Manage shared links",
|
||||||
"manage_sharing_with_partners": "Manage sharing with partners",
|
"manage_sharing_with_partners": "Manage sharing with partners",
|
||||||
"manage_the_app_settings": "Manage the app settings",
|
"manage_the_app_settings": "Manage the app settings",
|
||||||
@@ -1508,6 +1520,7 @@
|
|||||||
"profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.",
|
"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_client_server_up_to_date": "Client and Server are up-to-date",
|
||||||
"profile_drawer_github": "GitHub",
|
"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_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_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}",
|
"profile_image_of_user": "Profile image of {user}",
|
||||||
@@ -1553,6 +1566,8 @@
|
|||||||
"rating_description": "Display the EXIF rating in the info panel",
|
"rating_description": "Display the EXIF rating in the info panel",
|
||||||
"reaction_options": "Reaction options",
|
"reaction_options": "Reaction options",
|
||||||
"read_changelog": "Read Changelog",
|
"read_changelog": "Read Changelog",
|
||||||
|
"readonly_mode_disabled": "Read-only mode disabled",
|
||||||
|
"readonly_mode_enabled": "Read-only mode enabled",
|
||||||
"reassign": "Reassign",
|
"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_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",
|
"reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person",
|
||||||
@@ -1722,6 +1737,7 @@
|
|||||||
"select_user_for_sharing_page_err_album": "Failed to create album",
|
"select_user_for_sharing_page_err_album": "Failed to create album",
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"selected_count": "{count, plural, other {# selected}}",
|
"selected_count": "{count, plural, other {# selected}}",
|
||||||
|
"selected_gps_coordinates": "selected gps coordinates",
|
||||||
"send_message": "Send message",
|
"send_message": "Send message",
|
||||||
"send_welcome_email": "Send welcome email",
|
"send_welcome_email": "Send welcome email",
|
||||||
"server_endpoint": "Server Endpoint",
|
"server_endpoint": "Server Endpoint",
|
||||||
@@ -1832,8 +1848,10 @@
|
|||||||
"shift_to_permanent_delete": "press ⇧ to permanently delete asset",
|
"shift_to_permanent_delete": "press ⇧ to permanently delete asset",
|
||||||
"show_album_options": "Show album options",
|
"show_album_options": "Show album options",
|
||||||
"show_albums": "Show albums",
|
"show_albums": "Show albums",
|
||||||
|
"show_all_assets": "Show all assets",
|
||||||
"show_all_people": "Show all people",
|
"show_all_people": "Show all people",
|
||||||
"show_and_hide_people": "Show & hide people",
|
"show_and_hide_people": "Show & hide people",
|
||||||
|
"show_assets_without_location": "Show assets without location",
|
||||||
"show_file_location": "Show file location",
|
"show_file_location": "Show file location",
|
||||||
"show_gallery": "Show gallery",
|
"show_gallery": "Show gallery",
|
||||||
"show_hidden_people": "Show hidden people",
|
"show_hidden_people": "Show hidden people",
|
||||||
@@ -1993,6 +2011,7 @@
|
|||||||
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
|
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
|
||||||
"untagged": "Untagged",
|
"untagged": "Untagged",
|
||||||
"up_next": "Up next",
|
"up_next": "Up next",
|
||||||
|
"update_location_action_prompt": "Update the location of {count} selected assets with:",
|
||||||
"updated_at": "Updated",
|
"updated_at": "Updated",
|
||||||
"updated_password": "Updated password",
|
"updated_password": "Updated password",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
@@ -2017,6 +2036,7 @@
|
|||||||
"use_biometric": "Use biometric",
|
"use_biometric": "Use biometric",
|
||||||
"use_current_connection": "use current connection",
|
"use_current_connection": "use current connection",
|
||||||
"use_custom_date_range": "Use custom date range instead",
|
"use_custom_date_range": "Use custom date range instead",
|
||||||
|
"use_this_location": "Click to use location",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"user_has_been_deleted": "This user has been deleted.",
|
"user_has_been_deleted": "This user has been deleted.",
|
||||||
"user_id": "User ID",
|
"user_id": "User ID",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
|||||||
pnpm install --frozen-lockfile --prefix server
|
pnpm install --frozen-lockfile --prefix server
|
||||||
pnpm --prefix server run build
|
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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ custom_lint:
|
|||||||
# acceptable exceptions for the time being (until Isar is fully replaced)
|
# acceptable exceptions for the time being (until Isar is fully replaced)
|
||||||
- lib/providers/app_life_cycle.provider.dart
|
- lib/providers/app_life_cycle.provider.dart
|
||||||
- integration_test/test_utils/general_helper.dart
|
- integration_test/test_utils/general_helper.dart
|
||||||
|
- lib/domain/services/background_worker.service.dart
|
||||||
- lib/main.dart
|
- lib/main.dart
|
||||||
- lib/pages/album/album_asset_selection.page.dart
|
- lib/pages/album/album_asset_selection.page.dart
|
||||||
- lib/routing/router.dart
|
- lib/routing/router.dart
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import androidx.work.Configuration
|
|||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
|
||||||
class ImmichApp : Application() {
|
class ImmichApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
val config = Configuration.Builder().build()
|
val config = Configuration.Builder().build()
|
||||||
WorkManager.initialize(this, config)
|
WorkManager.initialize(this, config)
|
||||||
// always start BackupWorker after WorkManager init; this fixes the following bug:
|
// 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.
|
// 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
|
// 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.
|
// (because of low memory etc.), the backup is never performed.
|
||||||
// As a workaround, we also run a backup check when initializing the application
|
// As a workaround, we also run a backup check when initializing the application
|
||||||
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ext.SdkExtensions
|
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.ThumbnailApi
|
||||||
import app.alextran.immich.images.ThumbnailsImpl
|
import app.alextran.immich.images.ThumbnailsImpl
|
||||||
import app.alextran.immich.sync.NativeSyncApi
|
import app.alextran.immich.sync.NativeSyncApi
|
||||||
@@ -12,19 +14,26 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
|||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
class MainActivity : FlutterFragmentActivity() {
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
registerPlugins(this, flutterEngine)
|
||||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
}
|
||||||
// No need to set up method channel here as it's now handled in the plugin
|
|
||||||
|
|
||||||
val nativeSyncApiImpl =
|
companion object {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
NativeSyncApiImpl26(this)
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
} else {
|
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||||
NativeSyncApiImpl30(this)
|
|
||||||
}
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl)
|
val nativeSyncApiImpl =
|
||||||
ThumbnailApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ThumbnailsImpl(this))
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 3009,
|
"android.injected.version.code" => 3010,
|
||||||
"android.injected.version.name" => "1.139.4",
|
"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')
|
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')
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
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 */; };
|
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
||||||
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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; };
|
F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
@@ -237,6 +243,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
||||||
65DD438629917FAD0047FFA8 /* BackgroundSync */,
|
65DD438629917FAD0047FFA8 /* BackgroundSync */,
|
||||||
@@ -254,6 +261,16 @@
|
|||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
B21E34A62E5AF9760031FDB9 /* Background */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */,
|
||||||
|
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */,
|
||||||
|
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */,
|
||||||
|
);
|
||||||
|
path = Background;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -540,10 +557,13 @@
|
|||||||
files = (
|
files = (
|
||||||
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
|
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
|
||||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
||||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
|
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
|
||||||
|
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
|
||||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
|
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
|
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
|
||||||
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
|
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -669,7 +689,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 217;
|
CURRENT_PROJECT_VERSION = 218;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -813,7 +833,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 217;
|
CURRENT_PROJECT_VERSION = 218;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -843,7 +863,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 217;
|
CURRENT_PROJECT_VERSION = 218;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -877,7 +897,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 217;
|
CURRENT_PROJECT_VERSION = 218;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -920,7 +940,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 217;
|
CURRENT_PROJECT_VERSION = 218;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -960,7 +980,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 217;
|
CURRENT_PROJECT_VERSION = 218;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -999,7 +1019,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 217;
|
CURRENT_PROJECT_VERSION = 218;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -1043,7 +1063,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 217;
|
CURRENT_PROJECT_VERSION = 218;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -1084,7 +1104,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 217;
|
CURRENT_PROJECT_VERSION = 218;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
|||||||
@@ -19,13 +19,12 @@ import UIKit
|
|||||||
}
|
}
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
|
||||||
|
|
||||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
|
||||||
|
|
||||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
NativeSyncApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: NativeSyncApiImpl())
|
AppDelegate.registerPlugins(binaryMessenger: controller.binaryMessenger)
|
||||||
ThumbnailApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ThumbnailApiImpl())
|
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||||
|
|
||||||
|
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||||
|
BackgroundWorkerApiImpl.registerBackgroundProcessing()
|
||||||
|
|
||||||
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
||||||
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
|
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
|
||||||
@@ -51,4 +50,10 @@ import UIKit
|
|||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
245
mobile/ios/Runner/Background/BackgroundWorker.g.swift
Normal file
245
mobile/ios/Runner/Background/BackgroundWorker.g.swift
Normal 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(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
202
mobile/ios/Runner/Background/BackgroundWorker.swift
Normal file
202
mobile/ios/Runner/Background/BackgroundWorker.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
155
mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift
Normal file
155
mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
<string>$(CUSTOM_GROUP_ID)</string>
|
<string>$(CUSTOM_GROUP_ID)</string>
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<array>
|
<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.backgroundFetch</string>
|
||||||
<string>app.alextran.immich.backgroundProcessing</string>
|
<string>app.alextran.immich.backgroundProcessing</string>
|
||||||
</array>
|
</array>
|
||||||
@@ -134,6 +137,9 @@
|
|||||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
<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>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||||
<key>NSLocationUsageDescription</key>
|
<key>NSLocationUsageDescription</key>
|
||||||
@@ -180,8 +186,5 @@
|
|||||||
<true />
|
<true />
|
||||||
<key>io.flutter.embedded_views_preview</key>
|
<key>io.flutter.embedded_views_preview</key>
|
||||||
<true />
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -22,7 +22,7 @@ platform :ios do
|
|||||||
path: "./Runner.xcodeproj",
|
path: "./Runner.xcodeproj",
|
||||||
)
|
)
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.139.4"
|
version_number: "1.140.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -67,13 +67,16 @@ enum StoreKey<T> {
|
|||||||
loadOriginalVideo<bool>._(136),
|
loadOriginalVideo<bool>._(136),
|
||||||
manageLocalMediaAndroid<bool>._(137),
|
manageLocalMediaAndroid<bool>._(137),
|
||||||
|
|
||||||
|
// Read-only Mode settings
|
||||||
|
readonlyModeEnabled<bool>._(138),
|
||||||
|
|
||||||
// Experimental stuff
|
// Experimental stuff
|
||||||
photoManagerCustomFilter<bool>._(1000),
|
photoManagerCustomFilter<bool>._(1000),
|
||||||
betaPromptShown<bool>._(1001),
|
betaPromptShown<bool>._(1001),
|
||||||
betaTimeline<bool>._(1002),
|
betaTimeline<bool>._(1002),
|
||||||
enableBackup<bool>._(1003),
|
enableBackup<bool>._(1003),
|
||||||
useWifiForUploadVideos<bool>._(1004),
|
useCellularForUploadVideos<bool>._(1004),
|
||||||
useWifiForUploadPhotos<bool>._(1005);
|
useCellularForUploadPhotos<bool>._(1005);
|
||||||
|
|
||||||
const StoreKey._(this.id);
|
const StoreKey._(this.id);
|
||||||
final int id;
|
final int id;
|
||||||
|
|||||||
232
mobile/lib/domain/services/background_worker.service.dart
Normal file
232
mobile/lib/domain/services/background_worker.service.dart
Normal 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();
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ class HashService {
|
|||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final StorageRepository _storageRepository;
|
final StorageRepository _storageRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
|
final bool Function()? _cancelChecker;
|
||||||
final _log = Logger('HashService');
|
final _log = Logger('HashService');
|
||||||
|
|
||||||
HashService({
|
HashService({
|
||||||
@@ -22,13 +23,17 @@ class HashService {
|
|||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required StorageRepository storageRepository,
|
required StorageRepository storageRepository,
|
||||||
required NativeSyncApi nativeSyncApi,
|
required NativeSyncApi nativeSyncApi,
|
||||||
|
bool Function()? cancelChecker,
|
||||||
this.batchSizeLimit = kBatchHashSizeLimit,
|
this.batchSizeLimit = kBatchHashSizeLimit,
|
||||||
this.batchFileLimit = kBatchHashFileLimit,
|
this.batchFileLimit = kBatchHashFileLimit,
|
||||||
}) : _localAlbumRepository = localAlbumRepository,
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_storageRepository = storageRepository,
|
_storageRepository = storageRepository,
|
||||||
|
_cancelChecker = cancelChecker,
|
||||||
_nativeSyncApi = nativeSyncApi;
|
_nativeSyncApi = nativeSyncApi;
|
||||||
|
|
||||||
|
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||||
|
|
||||||
Future<void> hashAssets() async {
|
Future<void> hashAssets() async {
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
// Sorted by backupSelection followed by isCloud
|
// Sorted by backupSelection followed by isCloud
|
||||||
@@ -37,6 +42,11 @@ class HashService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (final album in localAlbums) {
|
for (final album in localAlbums) {
|
||||||
|
if (isCancelled) {
|
||||||
|
_log.warning("Hashing cancelled. Stopped processing albums.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
||||||
if (assetsToHash.isNotEmpty) {
|
if (assetsToHash.isNotEmpty) {
|
||||||
await _hashAssets(assetsToHash);
|
await _hashAssets(assetsToHash);
|
||||||
@@ -55,6 +65,11 @@ class HashService {
|
|||||||
final toHash = <_AssetToPath>[];
|
final toHash = <_AssetToPath>[];
|
||||||
|
|
||||||
for (final asset in assetsToHash) {
|
for (final asset in assetsToHash) {
|
||||||
|
if (isCancelled) {
|
||||||
|
_log.warning("Hashing cancelled. Stopped processing assets.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
continue;
|
continue;
|
||||||
@@ -89,6 +104,11 @@ class HashService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (int i = 0; i < hashes.length; i++) {
|
for (int i = 0; i < hashes.length; i++) {
|
||||||
|
if (isCancelled) {
|
||||||
|
_log.warning("Hashing cancelled. Stopped processing batch.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final hash = hashes[i];
|
final hash = hashes[i];
|
||||||
final asset = toHash[i].asset;
|
final asset = toHash[i].asset;
|
||||||
if (hash?.length == 20) {
|
if (hash?.length == 20) {
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ class LogService {
|
|||||||
_flushTimer = null;
|
_flushTimer = null;
|
||||||
final buffer = [..._msgBuffer];
|
final buffer = [..._msgBuffer];
|
||||||
_msgBuffer.clear();
|
_msgBuffer.clear();
|
||||||
|
|
||||||
|
if (buffer.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _logRepository.insertAll(buffer);
|
await _logRepository.insertAll(buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// No need to cancel the task, as it can also be run when the user logs out
|
||||||
Future<void> syncLocal({bool full = false}) {
|
Future<void> syncLocal({bool full = false}) {
|
||||||
if (_deviceAlbumSyncTask != null) {
|
if (_deviceAlbumSyncTask != null) {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
|
|||||||
..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
|
..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
|
||||||
|
|
||||||
final rows = await query.get();
|
final rows = await query.get();
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, DriftMemory> memoriesMap = {};
|
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 {
|
Future<DriftMemory?> get(String memoryId) async {
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/constants/locales.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/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/locale_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/providers/theme.provider.dart';
|
||||||
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/deep_link.service.dart';
|
import 'package:immich_mobile/services/deep_link.service.dart';
|
||||||
import 'package:immich_mobile/services/local_notification.service.dart';
|
import 'package:immich_mobile/services/local_notification.service.dart';
|
||||||
@@ -165,36 +169,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
|||||||
await ref.read(localNotificationService).setup();
|
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 {
|
Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async {
|
||||||
final deepLinkHandler = ref.read(deepLinkServiceProvider);
|
final deepLinkHandler = ref.read(deepLinkServiceProvider);
|
||||||
final currentRouteName = ref.read(currentRouteNameProvider.notifier).state;
|
final currentRouteName = ref.read(currentRouteNameProvider.notifier).state;
|
||||||
@@ -221,7 +195,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
|||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
Intl.defaultLocale = context.locale.toLanguageTag();
|
Intl.defaultLocale = context.locale.toLanguageTag();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_configureFileDownloaderNotifications();
|
configureFileDownloaderNotifications();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +205,16 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
|||||||
initApp().then((_) => debugPrint("App Init Completed"));
|
initApp().then((_) => debugPrint("App Init Completed"));
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// needs to be delayed so that EasyLocalization is working
|
// 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();
|
ref.read(shareIntentUploadProvider.notifier).init();
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -42,10 +45,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
|
|
||||||
await ref.read(backgroundSyncProvider).syncRemote();
|
await ref.read(backgroundSyncProvider).syncRemote();
|
||||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||||
|
await ref.read(driftBackgroundUploadFgService).enableUploadService();
|
||||||
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
|
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopBackup() async {
|
Future<void> stopBackup() async {
|
||||||
|
await ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||||
await ref.read(driftBackupProvider.notifier).cancel();
|
await ref.read(driftBackupProvider.notifier).cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +95,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
const _BackupCard(),
|
const _BackupCard(),
|
||||||
const _RemainderCard(),
|
const _RemainderCard(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const _CellularBackupStatus(),
|
||||||
|
const SizedBox(height: 4),
|
||||||
BackupToggleButton(onStart: () async => await startBackup(), onStop: () async => await stopBackup()),
|
BackupToggleButton(onStart: () async => await startBackup(), onStop: () async => await stopBackup()),
|
||||||
|
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.info_outline_rounded),
|
icon: const Icon(Icons.info_outline_rounded),
|
||||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||||
@@ -106,6 +115,64 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CellularBackupStatus extends ConsumerWidget {
|
||||||
|
const _CellularBackupStatus();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cellularReqForVideos = Store.watch(StoreKey.useCellularForUploadVideos);
|
||||||
|
final cellularReqForPhotos = Store.watch(StoreKey.useCellularForUploadPhotos);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => context.pushRoute(const DriftBackupOptionsRoute()),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
StreamBuilder(
|
||||||
|
stream: cellularReqForVideos,
|
||||||
|
initialData: Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return Expanded(
|
||||||
|
child: ListTile(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading: Icon(
|
||||||
|
snapshot.data ?? false ? Icons.check_circle : Icons.cancel_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: snapshot.data ?? false ? Colors.green : context.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
"cellular_data_for_videos".t(context: context),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
StreamBuilder(
|
||||||
|
stream: cellularReqForPhotos,
|
||||||
|
initialData: Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return Expanded(
|
||||||
|
child: ListTile(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading: Icon(
|
||||||
|
snapshot.data ?? false ? Icons.check_circle : Icons.cancel_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: snapshot.data ?? false ? Colors.green : context.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
"cellular_data_for_photos".t(context: context),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _BackupAlbumSelectionCard extends ConsumerWidget {
|
class _BackupAlbumSelectionCard extends ConsumerWidget {
|
||||||
const _BackupAlbumSelectionCard();
|
const _BackupAlbumSelectionCard();
|
||||||
|
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
bool hasPopped = false;
|
bool hasPopped = false;
|
||||||
final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
|
final previousWifiReqForVideos = Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false;
|
||||||
final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
|
final previousWifiReqForPhotos = Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false;
|
||||||
return PopScope(
|
return PopScope(
|
||||||
onPopInvokedWithResult: (didPop, result) async {
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
// There is an issue with Flutter where the pop event
|
// There is an issue with Flutter where the pop event
|
||||||
// can be triggered multiple times, so we guard it with _hasPopped
|
// can be triggered multiple times, so we guard it with _hasPopped
|
||||||
|
|
||||||
final currentWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
|
final currentWifiReqForVideos = Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false;
|
||||||
final currentWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
|
final currentWifiReqForPhotos = Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false;
|
||||||
|
|
||||||
if (currentWifiReqForVideos == previousWifiReqForVideos &&
|
if (currentWifiReqForVideos == previousWifiReqForVideos &&
|
||||||
currentWifiReqForPhotos == previousWifiReqForPhotos) {
|
currentWifiReqForPhotos == previousWifiReqForPhotos) {
|
||||||
|
|||||||
@@ -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/backup/manual_upload.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.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/providers/websocket.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/utils/migration.dart';
|
import 'package:immich_mobile/utils/migration.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:permission_handler/permission_handler.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 migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider));
|
||||||
await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider));
|
await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider));
|
||||||
await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider));
|
await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider));
|
||||||
|
await ref.read(backgroundServiceProvider).disableService();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await ref.read(backgroundSyncProvider).cancel();
|
await ref.read(backgroundSyncProvider).cancel();
|
||||||
ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
|
ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
|
||||||
ref.read(websocketProvider.notifier).startListeningToOldEvents();
|
ref.read(websocketProvider.notifier).startListeningToOldEvents();
|
||||||
|
ref.read(readonlyModeProvider.notifier).setReadonlyMode(false);
|
||||||
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
|
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
|
||||||
|
await ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||||
|
await ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||||
}
|
}
|
||||||
|
|
||||||
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
|
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
|
||||||
|
|||||||
@@ -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/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.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/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/search/search_input_focus.provider.dart';
|
||||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
@@ -54,6 +55,7 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isScreenLandscape = context.orientation == Orientation.landscape;
|
final isScreenLandscape = context.orientation == Orientation.landscape;
|
||||||
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
final navigationDestinations = [
|
final navigationDestinations = [
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
@@ -65,16 +67,19 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
|||||||
label: 'search'.tr(),
|
label: 'search'.tr(),
|
||||||
icon: const Icon(Icons.search_rounded),
|
icon: const Icon(Icons.search_rounded),
|
||||||
selectedIcon: Icon(Icons.search, color: context.primaryColor),
|
selectedIcon: Icon(Icons.search, color: context.primaryColor),
|
||||||
|
enabled: !isReadonlyModeEnabled,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
label: 'albums'.tr(),
|
label: 'albums'.tr(),
|
||||||
icon: const Icon(Icons.photo_album_outlined),
|
icon: const Icon(Icons.photo_album_outlined),
|
||||||
selectedIcon: Icon(Icons.photo_album_rounded, color: context.primaryColor),
|
selectedIcon: Icon(Icons.photo_album_rounded, color: context.primaryColor),
|
||||||
|
enabled: !isReadonlyModeEnabled,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
label: 'library'.tr(),
|
label: 'library'.tr(),
|
||||||
icon: const Icon(Icons.space_dashboard_outlined),
|
icon: const Icon(Icons.space_dashboard_outlined),
|
||||||
selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor),
|
selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor),
|
||||||
|
enabled: !isReadonlyModeEnabled,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
296
mobile/lib/platform/background_worker_api.g.dart
generated
Normal file
296
mobile/lib/platform/background_worker_api.g.dart
generated
Normal 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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/memory/memory_lane.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.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/infrastructure/memory.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class MainTimelinePage extends ConsumerWidget {
|
class MainTimelinePage extends ConsumerWidget {
|
||||||
@@ -12,22 +11,10 @@ class MainTimelinePage extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
|
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||||
final memoriesEnabled = ref.watch(currentUserProvider.select((user) => user?.memoryEnabled ?? true));
|
return Timeline(
|
||||||
|
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||||
return memoryLaneProvider.maybeWhen(
|
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
||||||
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(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/asset_viewer/video_player_value_provider.dart';
|
||||||
import 'package:immich_mobile/providers/cast.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/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/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.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);
|
bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distanceToOrigin > openThreshold && !showingBottomSheet) {
|
if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) {
|
||||||
_openBottomSheet(ctx);
|
_openBottomSheet(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/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/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
||||||
@@ -26,6 +27,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||||
@@ -60,7 +62,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
duration: Durations.short2,
|
duration: Durations.short2,
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: Durations.short4,
|
duration: Durations.short4,
|
||||||
child: isSheetOpen
|
child: isSheetOpen || isReadonlyModeEnabled
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Theme(
|
: Theme(
|
||||||
data: context.themeData.copyWith(
|
data: context.themeData.copyWith(
|
||||||
|
|||||||
@@ -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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/providers/cast.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/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/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.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 user = ref.watch(currentUserProvider);
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
final previousRouteName = ref.watch(previousRouteNameProvider);
|
final previousRouteName = ref.watch(previousRouteNameProvider);
|
||||||
final showViewInTimelineButton =
|
final showViewInTimelineButton =
|
||||||
@@ -94,7 +96,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
shape: const Border(),
|
shape: const Border(),
|
||||||
actions: isShowingSheet
|
actions: isShowingSheet || isReadonlyModeEnabled
|
||||||
? null
|
? null
|
||||||
: isInLockedView
|
: isInLockedView
|
||||||
? lockedViewActions
|
? lockedViewActions
|
||||||
|
|||||||
@@ -50,12 +50,11 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
|||||||
|
|
||||||
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
|
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
|
this.request = null;
|
||||||
evict();
|
evict();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.request = request;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final image = await request.load(decode);
|
final image = await request.load(decode);
|
||||||
if (image == null || isCancelled) {
|
if (image == null || isCancelled) {
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
|
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
|
@override
|
||||||
@@ -87,7 +88,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
}
|
}
|
||||||
|
|
||||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||||
final request = LocalImageRequest(
|
final request = this.request = LocalImageRequest(
|
||||||
localId: key.id,
|
localId: key.id,
|
||||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||||
assetType: key.assetType,
|
assetType: key.assetType,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
||||||
final request = RemoteImageRequest(
|
final request = this.request = RemoteImageRequest(
|
||||||
uri: getThumbnailUrlForRemoteId(key.assetId),
|
uri: getThumbnailUrlForRemoteId(key.assetId),
|
||||||
headers: ApiService.getRequestHeaders(),
|
headers: ApiService.getRequestHeaders(),
|
||||||
cacheManager: cacheManager,
|
cacheManager: cacheManager,
|
||||||
@@ -92,16 +92,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
}
|
}
|
||||||
|
|
||||||
final headers = ApiService.getRequestHeaders();
|
final headers = ApiService.getRequestHeaders();
|
||||||
try {
|
final request = this.request = RemoteImageRequest(
|
||||||
final request = RemoteImageRequest(
|
uri: getPreviewUrlForRemoteId(key.assetId),
|
||||||
uri: getPreviewUrlForRemoteId(key.assetId),
|
headers: headers,
|
||||||
headers: headers,
|
cacheManager: cacheManager,
|
||||||
cacheManager: cacheManager,
|
);
|
||||||
);
|
yield* loadRequest(request, decode);
|
||||||
yield* loadRequest(request, decode);
|
|
||||||
} finally {
|
|
||||||
request = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
evict();
|
evict();
|
||||||
@@ -109,12 +105,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (AppSetting.get(Setting.loadOriginal)) {
|
if (AppSetting.get(Setting.loadOriginal)) {
|
||||||
try {
|
final request = this.request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
|
||||||
final request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
|
yield* loadRequest(request, decode);
|
||||||
yield* loadRequest(request, decode);
|
|
||||||
} finally {
|
|
||||||
request = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
|
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) {
|
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
|
@override
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_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/local_image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/remote_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';
|
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||||
@@ -235,6 +236,16 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
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.removeStatusListener(_onAnimationStatusChanged);
|
||||||
_fadeController.dispose();
|
_fadeController.dispose();
|
||||||
_stopListeningToStream();
|
_stopListeningToStream();
|
||||||
|
|||||||
@@ -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/asset_viewer/video_player_value_provider.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.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/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class DriftMemoryLane extends ConsumerWidget {
|
class DriftMemoryLane extends ConsumerWidget {
|
||||||
final List<DriftMemory> memories;
|
const DriftMemoryLane({super.key});
|
||||||
|
|
||||||
const DriftMemoryLane({super.key, required this.memories});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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(
|
return ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 200),
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
child: CarouselView(
|
child: CarouselView(
|
||||||
@@ -38,7 +43,9 @@ class DriftMemoryLane extends ConsumerWidget {
|
|||||||
|
|
||||||
context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index));
|
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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.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/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/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
@@ -190,11 +191,12 @@ class _AssetTileWidget extends ConsumerWidget {
|
|||||||
|
|
||||||
final lockSelection = _getLockSelectionStatus(ref);
|
final lockSelection = _getLockSelectionStatus(ref);
|
||||||
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
|
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
|
||||||
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
|
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
|
||||||
onLongPress: () => lockSelection ? null : _handleOnLongPress(ref, asset),
|
onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset),
|
||||||
child: ThumbnailTile(
|
child: ThumbnailTile(
|
||||||
asset,
|
asset,
|
||||||
lockSelection: lockSelection,
|
lockSelection: lockSelection,
|
||||||
|
|||||||
@@ -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/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.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/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/providers/timeline/multiselect.provider.dart';
|
||||||
|
|
||||||
class TimelineHeader extends StatelessWidget {
|
class TimelineHeader extends HookConsumerWidget {
|
||||||
final Bucket bucket;
|
final Bucket bucket;
|
||||||
final HeaderType header;
|
final HeaderType header;
|
||||||
final double height;
|
final double height;
|
||||||
@@ -36,13 +37,12 @@ class TimelineHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
if (bucket is! TimeBucket || header == HeaderType.none) {
|
if (bucket is! TimeBucket || header == HeaderType.none) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final date = (bucket as TimeBucket).date;
|
final date = (bucket as TimeBucket).date;
|
||||||
|
|
||||||
final isMonthHeader = header == HeaderType.month || header == HeaderType.monthAndDay;
|
final isMonthHeader = header == HeaderType.month || header == HeaderType.monthAndDay;
|
||||||
final isDayHeader = header == HeaderType.day || header == HeaderType.monthAndDay;
|
final isDayHeader = header == HeaderType.day || header == HeaderType.monthAndDay;
|
||||||
|
|
||||||
@@ -98,16 +98,19 @@ class _BulkSelectIconButton extends ConsumerWidget {
|
|||||||
bucketAssets = <BaseAsset>[];
|
bucketAssets = <BaseAsset>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
final isAllSelected = ref.watch(bucketSelectionProvider(bucketAssets));
|
final isAllSelected = ref.watch(bucketSelectionProvider(bucketAssets));
|
||||||
|
|
||||||
return IconButton(
|
return isReadonlyModeEnabled
|
||||||
onPressed: () {
|
? const SizedBox.shrink()
|
||||||
ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount);
|
: IconButton(
|
||||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
onPressed: () {
|
||||||
},
|
ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount);
|
||||||
icon: isAllSelected
|
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||||
? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor)
|
},
|
||||||
: Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary),
|
icon: isAllSelected
|
||||||
);
|
? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor)
|
||||||
|
: Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/segment.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.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/setting.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.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 maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
|
||||||
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
|
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
|
||||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||||
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: !isMultiSelectEnabled,
|
canPop: !isMultiSelectEnabled,
|
||||||
@@ -342,9 +344,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
child: TimelineDragRegion(
|
child: TimelineDragRegion(
|
||||||
onStart: _setDragStartIndex,
|
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||||
onAssetEnter: _handleDragAssetEnter,
|
onAssetEnter: _handleDragAssetEnter,
|
||||||
onEnd: _stopDrag,
|
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
|
||||||
onScroll: _dragScroll,
|
onScroll: _dragScroll,
|
||||||
onScrollStart: () {
|
onScrollStart: () {
|
||||||
// Minimize the bottom sheet when drag selection starts
|
// Minimize the bottom sheet when drag selection starts
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.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/error_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/success_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/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/app_life_cycle.provider.dart';
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/error_backup_list.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:permission_handler/permission_handler.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
|
final driftBackgroundUploadFgService = Provider((ref) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||||
|
|
||||||
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||||
return BackupNotifier(
|
return BackupNotifier(
|
||||||
ref.watch(backupServiceProvider),
|
ref.watch(backupServiceProvider),
|
||||||
|
|||||||
@@ -14,13 +14,12 @@ final driftMemoryServiceProvider = Provider<DriftMemoryService>(
|
|||||||
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
|
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) async {
|
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
||||||
final user = ref.watch(currentUserProvider);
|
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
|
||||||
if (user == null) {
|
if (userId == null || !enabled) {
|
||||||
return [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
final service = ref.watch(driftMemoryServiceProvider);
|
final service = ref.watch(driftMemoryServiceProvider);
|
||||||
|
return service.getMemoryLane(userId);
|
||||||
return service.getMemoryLane(user.id);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -27,8 +27,12 @@ class UploadRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void enqueueBackgroundAll(List<UploadTask> tasks) {
|
Future<void> enqueueBackground(UploadTask task) {
|
||||||
FileDownloader().enqueueAll(tasks);
|
return FileDownloader().enqueue(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> enqueueBackgroundAll(List<UploadTask> tasks) {
|
||||||
|
return FileDownloader().enqueueAll(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteDatabaseRecords(String group) {
|
Future<void> deleteDatabaseRecords(String group) {
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ enum AppSettingsEnum<T> {
|
|||||||
photoManagerCustomFilter<bool>(StoreKey.photoManagerCustomFilter, null, true),
|
photoManagerCustomFilter<bool>(StoreKey.photoManagerCustomFilter, null, true),
|
||||||
betaTimeline<bool>(StoreKey.betaTimeline, null, false),
|
betaTimeline<bool>(StoreKey.betaTimeline, null, false),
|
||||||
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
||||||
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
useCellularForUploadVideos<bool>(StoreKey.useCellularForUploadVideos, null, false),
|
||||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false);
|
useCellularForUploadPhotos<bool>(StoreKey.useCellularForUploadPhotos, null, false),
|
||||||
|
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||||
|
|
||||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
|||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
final uploadServiceProvider = Provider((ref) {
|
final uploadServiceProvider = Provider((ref) {
|
||||||
@@ -57,6 +58,7 @@ class UploadService {
|
|||||||
|
|
||||||
Stream<TaskStatusUpdate> get taskStatusStream => _taskStatusController.stream;
|
Stream<TaskStatusUpdate> get taskStatusStream => _taskStatusController.stream;
|
||||||
Stream<TaskProgressUpdate> get taskProgressStream => _taskProgressController.stream;
|
Stream<TaskProgressUpdate> get taskProgressStream => _taskProgressController.stream;
|
||||||
|
final Logger _log = Logger('UploadService');
|
||||||
|
|
||||||
bool shouldAbortQueuingTasks = false;
|
bool shouldAbortQueuingTasks = false;
|
||||||
|
|
||||||
@@ -78,8 +80,8 @@ class UploadService {
|
|||||||
_taskProgressController.close();
|
_taskProgressController.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
void enqueueTasks(List<UploadTask> tasks) {
|
Future<void> enqueueTasks(List<UploadTask> tasks) {
|
||||||
_uploadRepository.enqueueBackgroundAll(tasks);
|
return _uploadRepository.enqueueBackgroundAll(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Task>> getActiveTasks(String group) {
|
Future<List<Task>> getActiveTasks(String group) {
|
||||||
@@ -113,7 +115,7 @@ class UploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.isNotEmpty) {
|
if (tasks.isNotEmpty) {
|
||||||
enqueueTasks(tasks);
|
await enqueueTasks(tasks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +129,16 @@ class UploadService {
|
|||||||
|
|
||||||
final candidates = await _backupRepository.getCandidates(userId);
|
final candidates = await _backupRepository.getCandidates(userId);
|
||||||
if (candidates.isEmpty) {
|
if (candidates.isEmpty) {
|
||||||
|
_log.info("No backup candidates found for user $userId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.info("Starting backup for ${candidates.length} candidates");
|
||||||
|
onEnqueueTasks(EnqueueStatus(enqueueCount: 0, totalCount: candidates.length));
|
||||||
|
|
||||||
const batchSize = 100;
|
const batchSize = 100;
|
||||||
int count = 0;
|
int count = 0;
|
||||||
|
int skippedAssets = 0;
|
||||||
for (int i = 0; i < candidates.length; i += batchSize) {
|
for (int i = 0; i < candidates.length; i += batchSize) {
|
||||||
if (shouldAbortQueuingTasks) {
|
if (shouldAbortQueuingTasks) {
|
||||||
break;
|
break;
|
||||||
@@ -144,16 +151,56 @@ class UploadService {
|
|||||||
final task = await _getUploadTask(asset);
|
final task = await _getUploadTask(asset);
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
tasks.add(task);
|
tasks.add(task);
|
||||||
|
} else {
|
||||||
|
skippedAssets++;
|
||||||
|
_log.warning("Skipped asset ${asset.id} (${asset.name}) - unable to create upload task");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
|
count += tasks.length;
|
||||||
count += tasks.length;
|
onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length));
|
||||||
enqueueTasks(tasks);
|
|
||||||
|
|
||||||
onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length));
|
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
|
||||||
|
_log.info("Enqueuing ${tasks.length} upload tasks");
|
||||||
|
await enqueueTasks(tasks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.info("Upload queueing completed: $count tasks enqueued, $skippedAssets assets skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
debugPrint("No backup candidates found for serial backup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint("Starting serial backup for ${candidates.length} candidates");
|
||||||
|
int skippedAssets = 0;
|
||||||
|
int enqueuedTasks = 0;
|
||||||
|
|
||||||
|
for (final asset in candidates) {
|
||||||
|
if (shouldAbortQueuingTasks) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final task = await _getUploadTask(asset);
|
||||||
|
if (task != null) {
|
||||||
|
await _uploadRepository.enqueueBackground(task);
|
||||||
|
enqueuedTasks++;
|
||||||
|
} else {
|
||||||
|
skippedAssets++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint("Serial backup completed: $enqueuedTasks tasks enqueued, $skippedAssets assets skipped");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel all ongoing uploads and reset the upload queue
|
/// Cancel all ongoing uploads and reset the upload queue
|
||||||
@@ -221,6 +268,7 @@ class UploadService {
|
|||||||
Future<UploadTask?> _getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
Future<UploadTask?> _getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
|
_log.warning("Cannot get AssetEntity for asset ${asset.id} (${asset.name}) created on ${asset.createdAt}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +291,9 @@ class UploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
|
_log.warning(
|
||||||
|
"Cannot get file for asset ${asset.id} (${asset.name}) created on ${asset.createdAt} - file may have been deleted or moved",
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:flutter/foundation.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/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/store.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/duplicated_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||||
import 'package:immich_mobile/entities/ios_device_asset.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/device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/store.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:isar/isar.dart';
|
||||||
import 'package:path_provider/path_provider.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 {
|
abstract final class Bootstrap {
|
||||||
static Future<(Isar isar, Drift drift, DriftLogger logDb)> initDB() async {
|
static Future<(Isar isar, Drift drift, DriftLogger logDb)> initDB() async {
|
||||||
final drift = Drift();
|
final drift = Drift();
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ Cancelable<T?> runInIsolateGentle<T>({
|
|||||||
log.severe("Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}", error, stack);
|
log.severe("Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}", error, stack);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await LogService.I.flush();
|
await LogService.I.dispose();
|
||||||
await logDb.close();
|
await logDb.close();
|
||||||
await ref.read(driftProvider).close();
|
await ref.read(driftProvider).close();
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ Cancelable<T?> runInIsolateGentle<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
ref.dispose();
|
ref.dispose();
|
||||||
} catch (error) {
|
} catch (error, stack) {
|
||||||
debugPrint("Error closing resources in isolate: $error");
|
debugPrint("Error closing resources in isolate: $error, $stack");
|
||||||
} finally {
|
} finally {
|
||||||
ref.dispose();
|
ref.dispose();
|
||||||
// Delay to ensure all resources are released
|
// Delay to ensure all resources are released
|
||||||
|
|||||||
@@ -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/locale_provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.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/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.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 horizontalPadding = isHorizontal ? 100.0 : 20.0;
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
final isLoggingOut = useState(false);
|
final isLoggingOut = useState(false);
|
||||||
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
ref.read(backupProvider.notifier).updateDiskInfo();
|
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(
|
return Dismissible(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
direction: DismissDirection.down,
|
direction: DismissDirection.down,
|
||||||
@@ -238,6 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
const AppBarProfileInfoBox(),
|
const AppBarProfileInfoBox(),
|
||||||
buildStorageInformation(),
|
buildStorageInformation(),
|
||||||
const AppBarServerInfo(),
|
const AppBarServerInfo(),
|
||||||
|
if (isReadonlyModeEnabled) buildReadonlyMessage(),
|
||||||
buildAppLogButton(),
|
buildAppLogButton(),
|
||||||
buildSettingButton(),
|
buildSettingButton(),
|
||||||
buildSignOutButton(),
|
buildSignOutButton(),
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.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/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/auth.provider.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/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
|
import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -17,6 +20,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status;
|
final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status;
|
||||||
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
buildUserProfileImage() {
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -67,23 +90,25 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
|
|||||||
minLeadingWidth: 50,
|
minLeadingWidth: 50,
|
||||||
leading: GestureDetector(
|
leading: GestureDetector(
|
||||||
onTap: pickUserProfileImage,
|
onTap: pickUserProfileImage,
|
||||||
|
onDoubleTap: toggleReadonlyMode,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
buildUserProfileImage(),
|
buildUserProfileImage(),
|
||||||
Positioned(
|
if (!isReadonlyModeEnabled)
|
||||||
bottom: -5,
|
Positioned(
|
||||||
right: -8,
|
bottom: -5,
|
||||||
child: Material(
|
right: -8,
|
||||||
color: context.colorScheme.surfaceContainerHighest,
|
child: Material(
|
||||||
elevation: 3,
|
color: context.colorScheme.surfaceContainerHighest,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))),
|
elevation: 3,
|
||||||
child: Padding(
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))),
|
||||||
padding: const EdgeInsets.all(5.0),
|
child: Padding(
|
||||||
child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14),
|
padding: const EdgeInsets.all(5.0),
|
||||||
|
child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/cast.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/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/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
@@ -42,6 +43,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||||
|
|
||||||
return SliverAnimatedOpacity(
|
return SliverAnimatedOpacity(
|
||||||
@@ -57,7 +59,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
title: title ?? const _ImmichLogoWithText(),
|
title: title ?? const _ImmichLogoWithText(),
|
||||||
actions: [
|
actions: [
|
||||||
if (isCasting)
|
if (isCasting && !isReadonlyModeEnabled)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
@@ -70,12 +72,13 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
const _SyncStatusIndicator(),
|
const _SyncStatusIndicator(),
|
||||||
if (actions != null)
|
if (actions != null)
|
||||||
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
|
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
|
||||||
if (kDebugMode || kProfileMode)
|
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.science_rounded),
|
icon: const Icon(Icons.science_rounded),
|
||||||
onPressed: () => context.pushRoute(const FeatInDevRoute()),
|
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()),
|
const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -137,8 +140,24 @@ class _ProfileIndicator extends ConsumerWidget {
|
|||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
const widgetSize = 30.0;
|
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(
|
return InkWell(
|
||||||
onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
|
onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
|
||||||
|
onDoubleTap: () => toggleReadonlyMode(),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
child: Badge(
|
child: Badge(
|
||||||
label: Container(
|
label: Container(
|
||||||
|
|||||||
@@ -272,9 +272,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.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>(
|
_zoomAnimation = Tween<double>(
|
||||||
begin: 1.0,
|
begin: 1.0,
|
||||||
|
|||||||
@@ -378,9 +378,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.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>(
|
_zoomAnimation = Tween<double>(
|
||||||
begin: 1.0,
|
begin: 1.0,
|
||||||
|
|||||||
@@ -378,9 +378,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.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>(
|
_zoomAnimation = Tween<double>(
|
||||||
begin: 1.0,
|
begin: 1.0,
|
||||||
|
|||||||
@@ -172,12 +172,36 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_loading) {
|
if (_loading || _lastException != null) {
|
||||||
return _buildLoading(context);
|
return CustomChildWrapper(
|
||||||
}
|
childSize: null,
|
||||||
|
backgroundDecoration: widget.backgroundDecoration,
|
||||||
if (_lastException != null) {
|
heroAttributes: widget.heroAttributes,
|
||||||
return _buildError(context);
|
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(
|
final scaleBoundaries = ScaleBoundaries(
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
|
import 'package:immich_mobile/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/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/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.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 preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||||
final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
|
final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
|
||||||
final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter);
|
final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter);
|
||||||
|
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
|
||||||
|
|
||||||
final logLevel = Level.LEVELS[levelId.value].name;
|
final logLevel = Level.LEVELS[levelId.value].name;
|
||||||
|
|
||||||
@@ -102,6 +106,26 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
title: "advanced_settings_enable_alternate_media_filter_title".tr(),
|
title: "advanced_settings_enable_alternate_media_filter_title".tr(),
|
||||||
subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".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);
|
return SettingsSubPageScaffold(settings: advancedSettings);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final valueStream = Store.watch(StoreKey.useWifiForUploadVideos);
|
final valueStream = Store.watch(StoreKey.useCellularForUploadVideos);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -32,7 +32,7 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
|||||||
subtitle: Text("network_requirement_videos_upload".t(context: context), style: context.textTheme.labelLarge),
|
subtitle: Text("network_requirement_videos_upload".t(context: context), style: context.textTheme.labelLarge),
|
||||||
trailing: StreamBuilder(
|
trailing: StreamBuilder(
|
||||||
stream: valueStream,
|
stream: valueStream,
|
||||||
initialData: Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false,
|
initialData: Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final value = snapshot.data ?? false;
|
final value = snapshot.data ?? false;
|
||||||
return Switch(
|
return Switch(
|
||||||
@@ -54,7 +54,7 @@ class _UseWifiForUploadPhotosButton extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final valueStream = Store.watch(StoreKey.useWifiForUploadPhotos);
|
final valueStream = Store.watch(StoreKey.useCellularForUploadPhotos);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -64,7 +64,7 @@ class _UseWifiForUploadPhotosButton extends ConsumerWidget {
|
|||||||
subtitle: Text("network_requirement_photos_upload".t(context: context), style: context.textTheme.labelLarge),
|
subtitle: Text("network_requirement_photos_upload".t(context: context), style: context.textTheme.labelLarge),
|
||||||
trailing: StreamBuilder(
|
trailing: StreamBuilder(
|
||||||
stream: valueStream,
|
stream: valueStream,
|
||||||
initialData: Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false,
|
initialData: Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final value = snapshot.data ?? false;
|
final value = snapshot.data ?? false;
|
||||||
return Switch(
|
return Switch(
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ build:
|
|||||||
pigeon:
|
pigeon:
|
||||||
dart run pigeon --input pigeon/native_sync_api.dart
|
dart run pigeon --input pigeon/native_sync_api.dart
|
||||||
dart run pigeon --input pigeon/thumbnail_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/native_sync_api.g.dart
|
||||||
dart format lib/platform/thumbnail_api.g.dart
|
dart format lib/platform/thumbnail_api.g.dart
|
||||||
|
dart format lib/platform/background_worker_api.g.dart
|
||||||
|
|
||||||
watch:
|
watch:
|
||||||
dart run build_runner watch --delete-conflicting-outputs
|
dart run build_runner watch --delete-conflicting-outputs
|
||||||
|
|||||||
12
mobile/openapi/README.md
generated
12
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
|||||||
|
|
||||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 1.139.4
|
- API version: 1.140.0
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- 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} |
|
*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} |
|
||||||
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | checkBulkUpload
|
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | checkBulkUpload
|
||||||
*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | checkExistingAssets
|
*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* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets |
|
||||||
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original |
|
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original |
|
||||||
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId
|
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId
|
||||||
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} |
|
*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* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics |
|
||||||
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
|
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
|
||||||
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
|
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
|
||||||
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset
|
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset
|
||||||
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs |
|
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs |
|
||||||
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} |
|
*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* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |
|
||||||
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
|
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
|
||||||
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
|
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
|
||||||
@@ -328,6 +332,10 @@ Class | Method | HTTP request | Description
|
|||||||
- [AssetMediaResponseDto](doc//AssetMediaResponseDto.md)
|
- [AssetMediaResponseDto](doc//AssetMediaResponseDto.md)
|
||||||
- [AssetMediaSize](doc//AssetMediaSize.md)
|
- [AssetMediaSize](doc//AssetMediaSize.md)
|
||||||
- [AssetMediaStatus](doc//AssetMediaStatus.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)
|
- [AssetOrder](doc//AssetOrder.md)
|
||||||
- [AssetResponseDto](doc//AssetResponseDto.md)
|
- [AssetResponseDto](doc//AssetResponseDto.md)
|
||||||
- [AssetStackResponseDto](doc//AssetStackResponseDto.md)
|
- [AssetStackResponseDto](doc//AssetStackResponseDto.md)
|
||||||
@@ -485,6 +493,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [SyncAssetExifV1](doc//SyncAssetExifV1.md)
|
- [SyncAssetExifV1](doc//SyncAssetExifV1.md)
|
||||||
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
|
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
|
||||||
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)
|
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)
|
||||||
|
- [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md)
|
||||||
|
- [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md)
|
||||||
- [SyncAssetV1](doc//SyncAssetV1.md)
|
- [SyncAssetV1](doc//SyncAssetV1.md)
|
||||||
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
|
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
|
||||||
- [SyncEntityType](doc//SyncEntityType.md)
|
- [SyncEntityType](doc//SyncEntityType.md)
|
||||||
|
|||||||
6
mobile/openapi/lib/api.dart
generated
6
mobile/openapi/lib/api.dart
generated
@@ -106,6 +106,10 @@ part 'model/asset_jobs_dto.dart';
|
|||||||
part 'model/asset_media_response_dto.dart';
|
part 'model/asset_media_response_dto.dart';
|
||||||
part 'model/asset_media_size.dart';
|
part 'model/asset_media_size.dart';
|
||||||
part 'model/asset_media_status.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_order.dart';
|
||||||
part 'model/asset_response_dto.dart';
|
part 'model/asset_response_dto.dart';
|
||||||
part 'model/asset_stack_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_exif_v1.dart';
|
||||||
part 'model/sync_asset_face_delete_v1.dart';
|
part 'model/sync_asset_face_delete_v1.dart';
|
||||||
part 'model/sync_asset_face_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_asset_v1.dart';
|
||||||
part 'model/sync_auth_user_v1.dart';
|
part 'model/sync_auth_user_v1.dart';
|
||||||
part 'model/sync_entity_type.dart';
|
part 'model/sync_entity_type.dart';
|
||||||
|
|||||||
238
mobile/openapi/lib/api/assets_api.dart
generated
238
mobile/openapi/lib/api/assets_api.dart
generated
@@ -128,6 +128,56 @@ class AssetsApi {
|
|||||||
return null;
|
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.
|
/// This endpoint requires the `asset.delete` permission.
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// Note: This method returns the HTTP [Response].
|
||||||
@@ -368,6 +418,120 @@ class AssetsApi {
|
|||||||
return null;
|
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.
|
/// This endpoint requires the `asset.statistics` permission.
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// Note: This method returns the HTTP [Response].
|
||||||
@@ -795,6 +959,66 @@ class AssetsApi {
|
|||||||
return null;
|
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.
|
/// This endpoint requires the `asset.update` permission.
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// Note: This method returns the HTTP [Response].
|
||||||
@@ -855,6 +1079,8 @@ class AssetsApi {
|
|||||||
///
|
///
|
||||||
/// * [DateTime] fileModifiedAt (required):
|
/// * [DateTime] fileModifiedAt (required):
|
||||||
///
|
///
|
||||||
|
/// * [List<AssetMetadataUpsertItemDto>] metadata (required):
|
||||||
|
///
|
||||||
/// * [String] key:
|
/// * [String] key:
|
||||||
///
|
///
|
||||||
/// * [String] slug:
|
/// * [String] slug:
|
||||||
@@ -873,7 +1099,7 @@ class AssetsApi {
|
|||||||
/// * [MultipartFile] sidecarData:
|
/// * [MultipartFile] sidecarData:
|
||||||
///
|
///
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [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
|
// ignore: prefer_const_declarations
|
||||||
final apiPath = r'/assets';
|
final apiPath = r'/assets';
|
||||||
|
|
||||||
@@ -936,6 +1162,10 @@ class AssetsApi {
|
|||||||
hasFields = true;
|
hasFields = true;
|
||||||
mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId);
|
mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId);
|
||||||
}
|
}
|
||||||
|
if (metadata != null) {
|
||||||
|
hasFields = true;
|
||||||
|
mp.fields[r'metadata'] = parameterToString(metadata);
|
||||||
|
}
|
||||||
if (sidecarData != null) {
|
if (sidecarData != null) {
|
||||||
hasFields = true;
|
hasFields = true;
|
||||||
mp.fields[r'sidecarData'] = sidecarData.field;
|
mp.fields[r'sidecarData'] = sidecarData.field;
|
||||||
@@ -974,6 +1204,8 @@ class AssetsApi {
|
|||||||
///
|
///
|
||||||
/// * [DateTime] fileModifiedAt (required):
|
/// * [DateTime] fileModifiedAt (required):
|
||||||
///
|
///
|
||||||
|
/// * [List<AssetMetadataUpsertItemDto>] metadata (required):
|
||||||
|
///
|
||||||
/// * [String] key:
|
/// * [String] key:
|
||||||
///
|
///
|
||||||
/// * [String] slug:
|
/// * [String] slug:
|
||||||
@@ -992,8 +1224,8 @@ class AssetsApi {
|
|||||||
/// * [MultipartFile] sidecarData:
|
/// * [MultipartFile] sidecarData:
|
||||||
///
|
///
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [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 {
|
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, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, );
|
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) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
|||||||
12
mobile/openapi/lib/api_client.dart
generated
12
mobile/openapi/lib/api_client.dart
generated
@@ -266,6 +266,14 @@ class ApiClient {
|
|||||||
return AssetMediaSizeTypeTransformer().decode(value);
|
return AssetMediaSizeTypeTransformer().decode(value);
|
||||||
case 'AssetMediaStatus':
|
case 'AssetMediaStatus':
|
||||||
return AssetMediaStatusTypeTransformer().decode(value);
|
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':
|
case 'AssetOrder':
|
||||||
return AssetOrderTypeTransformer().decode(value);
|
return AssetOrderTypeTransformer().decode(value);
|
||||||
case 'AssetResponseDto':
|
case 'AssetResponseDto':
|
||||||
@@ -580,6 +588,10 @@ class ApiClient {
|
|||||||
return SyncAssetFaceDeleteV1.fromJson(value);
|
return SyncAssetFaceDeleteV1.fromJson(value);
|
||||||
case 'SyncAssetFaceV1':
|
case 'SyncAssetFaceV1':
|
||||||
return SyncAssetFaceV1.fromJson(value);
|
return SyncAssetFaceV1.fromJson(value);
|
||||||
|
case 'SyncAssetMetadataDeleteV1':
|
||||||
|
return SyncAssetMetadataDeleteV1.fromJson(value);
|
||||||
|
case 'SyncAssetMetadataV1':
|
||||||
|
return SyncAssetMetadataV1.fromJson(value);
|
||||||
case 'SyncAssetV1':
|
case 'SyncAssetV1':
|
||||||
return SyncAssetV1.fromJson(value);
|
return SyncAssetV1.fromJson(value);
|
||||||
case 'SyncAuthUserV1':
|
case 'SyncAuthUserV1':
|
||||||
|
|||||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@@ -67,6 +67,9 @@ String parameterToString(dynamic value) {
|
|||||||
if (value is AssetMediaStatus) {
|
if (value is AssetMediaStatus) {
|
||||||
return AssetMediaStatusTypeTransformer().encode(value).toString();
|
return AssetMediaStatusTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
if (value is AssetMetadataKey) {
|
||||||
|
return AssetMetadataKeyTypeTransformer().encode(value).toString();
|
||||||
|
}
|
||||||
if (value is AssetOrder) {
|
if (value is AssetOrder) {
|
||||||
return AssetOrderTypeTransformer().encode(value).toString();
|
return AssetOrderTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
|||||||
82
mobile/openapi/lib/model/asset_metadata_key.dart
generated
Normal file
82
mobile/openapi/lib/model/asset_metadata_key.dart
generated
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
115
mobile/openapi/lib/model/asset_metadata_response_dto.dart
generated
Normal file
115
mobile/openapi/lib/model/asset_metadata_response_dto.dart
generated
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
99
mobile/openapi/lib/model/asset_metadata_upsert_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/asset_metadata_upsert_dto.dart
generated
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
107
mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart
generated
Normal file
107
mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart
generated
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
107
mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart
generated
Normal file
107
mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart
generated
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
115
mobile/openapi/lib/model/sync_asset_metadata_v1.dart
generated
Normal file
115
mobile/openapi/lib/model/sync_asset_metadata_v1.dart
generated
Normal 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 SyncAssetMetadataV1 {
|
||||||
|
/// Returns a new [SyncAssetMetadataV1] instance.
|
||||||
|
SyncAssetMetadataV1({
|
||||||
|
required this.assetId,
|
||||||
|
required this.key,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
String assetId;
|
||||||
|
|
||||||
|
AssetMetadataKey key;
|
||||||
|
|
||||||
|
Object value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataV1 &&
|
||||||
|
other.assetId == assetId &&
|
||||||
|
other.key == key &&
|
||||||
|
other.value == value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(assetId.hashCode) +
|
||||||
|
(key.hashCode) +
|
||||||
|
(value.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SyncAssetMetadataV1[assetId=$assetId, key=$key, value=$value]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'assetId'] = this.assetId;
|
||||||
|
json[r'key'] = this.key;
|
||||||
|
json[r'value'] = this.value;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [SyncAssetMetadataV1] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static SyncAssetMetadataV1? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "SyncAssetMetadataV1");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return SyncAssetMetadataV1(
|
||||||
|
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||||
|
key: AssetMetadataKey.fromJson(json[r'key'])!,
|
||||||
|
value: mapValueOfType<Object>(json, r'value')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<SyncAssetMetadataV1> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <SyncAssetMetadataV1>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = SyncAssetMetadataV1.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, SyncAssetMetadataV1> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, SyncAssetMetadataV1>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = SyncAssetMetadataV1.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of SyncAssetMetadataV1-objects as value to a dart map
|
||||||
|
static Map<String, List<SyncAssetMetadataV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<SyncAssetMetadataV1>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = SyncAssetMetadataV1.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',
|
||||||
|
'value',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
6
mobile/openapi/lib/model/sync_entity_type.dart
generated
6
mobile/openapi/lib/model/sync_entity_type.dart
generated
@@ -29,6 +29,8 @@ class SyncEntityType {
|
|||||||
static const assetV1 = SyncEntityType._(r'AssetV1');
|
static const assetV1 = SyncEntityType._(r'AssetV1');
|
||||||
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
|
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
|
||||||
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
|
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
|
||||||
|
static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
|
||||||
|
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
|
||||||
static const partnerV1 = SyncEntityType._(r'PartnerV1');
|
static const partnerV1 = SyncEntityType._(r'PartnerV1');
|
||||||
static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
|
static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
|
||||||
static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
|
static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
|
||||||
@@ -76,6 +78,8 @@ class SyncEntityType {
|
|||||||
assetV1,
|
assetV1,
|
||||||
assetDeleteV1,
|
assetDeleteV1,
|
||||||
assetExifV1,
|
assetExifV1,
|
||||||
|
assetMetadataV1,
|
||||||
|
assetMetadataDeleteV1,
|
||||||
partnerV1,
|
partnerV1,
|
||||||
partnerDeleteV1,
|
partnerDeleteV1,
|
||||||
partnerAssetV1,
|
partnerAssetV1,
|
||||||
@@ -158,6 +162,8 @@ class SyncEntityTypeTypeTransformer {
|
|||||||
case r'AssetV1': return SyncEntityType.assetV1;
|
case r'AssetV1': return SyncEntityType.assetV1;
|
||||||
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
|
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
|
||||||
case r'AssetExifV1': return SyncEntityType.assetExifV1;
|
case r'AssetExifV1': return SyncEntityType.assetExifV1;
|
||||||
|
case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
|
||||||
|
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
|
||||||
case r'PartnerV1': return SyncEntityType.partnerV1;
|
case r'PartnerV1': return SyncEntityType.partnerV1;
|
||||||
case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
|
case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
|
||||||
case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;
|
case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;
|
||||||
|
|||||||
3
mobile/openapi/lib/model/sync_request_type.dart
generated
3
mobile/openapi/lib/model/sync_request_type.dart
generated
@@ -30,6 +30,7 @@ class SyncRequestType {
|
|||||||
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
|
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
|
||||||
static const assetsV1 = SyncRequestType._(r'AssetsV1');
|
static const assetsV1 = SyncRequestType._(r'AssetsV1');
|
||||||
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
|
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
|
||||||
|
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
|
||||||
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
|
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
|
||||||
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
|
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
|
||||||
static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1');
|
static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1');
|
||||||
@@ -52,6 +53,7 @@ class SyncRequestType {
|
|||||||
albumAssetExifsV1,
|
albumAssetExifsV1,
|
||||||
assetsV1,
|
assetsV1,
|
||||||
assetExifsV1,
|
assetExifsV1,
|
||||||
|
assetMetadataV1,
|
||||||
authUsersV1,
|
authUsersV1,
|
||||||
memoriesV1,
|
memoriesV1,
|
||||||
memoryToAssetsV1,
|
memoryToAssetsV1,
|
||||||
@@ -109,6 +111,7 @@ class SyncRequestTypeTypeTransformer {
|
|||||||
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
|
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
|
||||||
case r'AssetsV1': return SyncRequestType.assetsV1;
|
case r'AssetsV1': return SyncRequestType.assetsV1;
|
||||||
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
|
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
|
||||||
|
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
|
||||||
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
|
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
|
||||||
case r'MemoriesV1': return SyncRequestType.memoriesV1;
|
case r'MemoriesV1': return SyncRequestType.memoriesV1;
|
||||||
case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1;
|
case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1;
|
||||||
|
|||||||
48
mobile/pigeon/background_worker_api.dart
Normal file
48
mobile/pigeon/background_worker_api.dart
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:pigeon/pigeon.dart';
|
||||||
|
|
||||||
|
@ConfigurePigeon(
|
||||||
|
PigeonOptions(
|
||||||
|
dartOut: 'lib/platform/background_worker_api.g.dart',
|
||||||
|
swiftOut: 'ios/Runner/Background/BackgroundWorker.g.swift',
|
||||||
|
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||||
|
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt',
|
||||||
|
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.background'),
|
||||||
|
dartOptions: DartOptions(),
|
||||||
|
dartPackageName: 'immich_mobile',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@HostApi()
|
||||||
|
abstract class BackgroundWorkerFgHostApi {
|
||||||
|
void enableSyncWorker();
|
||||||
|
|
||||||
|
// Enables the background upload service with the given callback handle
|
||||||
|
void enableUploadWorker(int callbackHandle);
|
||||||
|
|
||||||
|
// Disables the background upload service
|
||||||
|
void disableUploadWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostApi()
|
||||||
|
abstract class BackgroundWorkerBgHostApi {
|
||||||
|
// Called from the background flutter engine when it has bootstrapped and established the
|
||||||
|
// required platform channels to notify the native side to start the background upload
|
||||||
|
void onInitialized();
|
||||||
|
}
|
||||||
|
|
||||||
|
@FlutterApi()
|
||||||
|
abstract class BackgroundWorkerFlutterApi {
|
||||||
|
// Android & iOS: Called when the local sync is triggered
|
||||||
|
@async
|
||||||
|
void onLocalSync(int? maxSeconds);
|
||||||
|
|
||||||
|
// iOS Only: Called when the iOS background upload is triggered
|
||||||
|
@async
|
||||||
|
void onIosUpload(bool isRefresh, int? maxSeconds);
|
||||||
|
|
||||||
|
// Android Only: Called when the Android background upload is triggered
|
||||||
|
@async
|
||||||
|
void onAndroidUpload();
|
||||||
|
|
||||||
|
@async
|
||||||
|
void cancel();
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.139.4+3009
|
version: 1.140.0+3010
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.8.0 <4.0.0'
|
sdk: '>=3.8.0 <4.0.0'
|
||||||
|
|||||||
@@ -2245,6 +2245,203 @@
|
|||||||
"description": "This endpoint requires the `asset.update` permission."
|
"description": "This endpoint requires the `asset.update` permission."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/assets/{id}/metadata": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAssetMetadata",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Assets"
|
||||||
|
],
|
||||||
|
"x-immich-permission": "asset.read",
|
||||||
|
"description": "This endpoint requires the `asset.read` permission."
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateAssetMetadata",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataUpsertDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Assets"
|
||||||
|
],
|
||||||
|
"x-immich-permission": "asset.update",
|
||||||
|
"description": "This endpoint requires the `asset.update` permission."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/assets/{id}/metadata/{key}": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "deleteAssetMetadata",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Assets"
|
||||||
|
],
|
||||||
|
"x-immich-permission": "asset.update",
|
||||||
|
"description": "This endpoint requires the `asset.update` permission."
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAssetMetadataByKey",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Assets"
|
||||||
|
],
|
||||||
|
"x-immich-permission": "asset.read",
|
||||||
|
"description": "This endpoint requires the `asset.read` permission."
|
||||||
|
}
|
||||||
|
},
|
||||||
"/assets/{id}/original": {
|
"/assets/{id}/original": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "downloadAsset",
|
"operationId": "downloadAsset",
|
||||||
@@ -9592,7 +9789,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "1.139.4",
|
"version": "1.140.0",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [],
|
"tags": [],
|
||||||
@@ -10615,6 +10812,12 @@
|
|||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"metadata": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataUpsertItemDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sidecarData": {
|
"sidecarData": {
|
||||||
"format": "binary",
|
"format": "binary",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -10632,7 +10835,8 @@
|
|||||||
"deviceAssetId",
|
"deviceAssetId",
|
||||||
"deviceId",
|
"deviceId",
|
||||||
"fileCreatedAt",
|
"fileCreatedAt",
|
||||||
"fileModifiedAt"
|
"fileModifiedAt",
|
||||||
|
"metadata"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -10707,6 +10911,69 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"AssetMetadataKey": {
|
||||||
|
"enum": [
|
||||||
|
"mobile-app"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"AssetMetadataResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataKey"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"key",
|
||||||
|
"updatedAt",
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AssetMetadataUpsertDto": {
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataUpsertItemDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"items"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AssetMetadataUpsertItemDto": {
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataKey"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"key",
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AssetOrder": {
|
"AssetOrder": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"asc",
|
"asc",
|
||||||
@@ -14944,6 +15211,48 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SyncAssetMetadataDeleteV1": {
|
||||||
|
"properties": {
|
||||||
|
"assetId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataKey"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"assetId",
|
||||||
|
"key"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SyncAssetMetadataV1": {
|
||||||
|
"properties": {
|
||||||
|
"assetId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataKey"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"assetId",
|
||||||
|
"key",
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SyncAssetV1": {
|
"SyncAssetV1": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"checksum": {
|
"checksum": {
|
||||||
@@ -15114,6 +15423,8 @@
|
|||||||
"AssetV1",
|
"AssetV1",
|
||||||
"AssetDeleteV1",
|
"AssetDeleteV1",
|
||||||
"AssetExifV1",
|
"AssetExifV1",
|
||||||
|
"AssetMetadataV1",
|
||||||
|
"AssetMetadataDeleteV1",
|
||||||
"PartnerV1",
|
"PartnerV1",
|
||||||
"PartnerDeleteV1",
|
"PartnerDeleteV1",
|
||||||
"PartnerAssetV1",
|
"PartnerAssetV1",
|
||||||
@@ -15373,6 +15684,7 @@
|
|||||||
"AlbumAssetExifsV1",
|
"AlbumAssetExifsV1",
|
||||||
"AssetsV1",
|
"AssetsV1",
|
||||||
"AssetExifsV1",
|
"AssetExifsV1",
|
||||||
|
"AssetMetadataV1",
|
||||||
"AuthUsersV1",
|
"AuthUsersV1",
|
||||||
"MemoriesV1",
|
"MemoriesV1",
|
||||||
"MemoryToAssetsV1",
|
"MemoryToAssetsV1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.139.4",
|
"version": "1.140.0",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 1.139.4
|
* 1.140.0
|
||||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||||
* See https://www.npmjs.com/package/oazapfts
|
* See https://www.npmjs.com/package/oazapfts
|
||||||
*/
|
*/
|
||||||
@@ -447,6 +447,10 @@ export type AssetBulkDeleteDto = {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
ids: string[];
|
ids: string[];
|
||||||
};
|
};
|
||||||
|
export type AssetMetadataUpsertItemDto = {
|
||||||
|
key: AssetMetadataKey;
|
||||||
|
value: object;
|
||||||
|
};
|
||||||
export type AssetMediaCreateDto = {
|
export type AssetMediaCreateDto = {
|
||||||
assetData: Blob;
|
assetData: Blob;
|
||||||
deviceAssetId: string;
|
deviceAssetId: string;
|
||||||
@@ -457,6 +461,7 @@ export type AssetMediaCreateDto = {
|
|||||||
filename?: string;
|
filename?: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
livePhotoVideoId?: string;
|
livePhotoVideoId?: string;
|
||||||
|
metadata: AssetMetadataUpsertItemDto[];
|
||||||
sidecarData?: Blob;
|
sidecarData?: Blob;
|
||||||
visibility?: AssetVisibility;
|
visibility?: AssetVisibility;
|
||||||
};
|
};
|
||||||
@@ -516,6 +521,14 @@ export type UpdateAssetDto = {
|
|||||||
rating?: number;
|
rating?: number;
|
||||||
visibility?: AssetVisibility;
|
visibility?: AssetVisibility;
|
||||||
};
|
};
|
||||||
|
export type AssetMetadataResponseDto = {
|
||||||
|
key: AssetMetadataKey;
|
||||||
|
updatedAt: string;
|
||||||
|
value: object;
|
||||||
|
};
|
||||||
|
export type AssetMetadataUpsertDto = {
|
||||||
|
items: AssetMetadataUpsertItemDto[];
|
||||||
|
};
|
||||||
export type AssetMediaReplaceDto = {
|
export type AssetMediaReplaceDto = {
|
||||||
assetData: Blob;
|
assetData: Blob;
|
||||||
deviceAssetId: string;
|
deviceAssetId: string;
|
||||||
@@ -2273,6 +2286,61 @@ export function updateAsset({ id, updateAssetDto }: {
|
|||||||
body: updateAssetDto
|
body: updateAssetDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This endpoint requires the `asset.read` permission.
|
||||||
|
*/
|
||||||
|
export function getAssetMetadata({ id }: {
|
||||||
|
id: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: AssetMetadataResponseDto[];
|
||||||
|
}>(`/assets/${encodeURIComponent(id)}/metadata`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This endpoint requires the `asset.update` permission.
|
||||||
|
*/
|
||||||
|
export function updateAssetMetadata({ id, assetMetadataUpsertDto }: {
|
||||||
|
id: string;
|
||||||
|
assetMetadataUpsertDto: AssetMetadataUpsertDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: AssetMetadataResponseDto[];
|
||||||
|
}>(`/assets/${encodeURIComponent(id)}/metadata`, oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "PUT",
|
||||||
|
body: assetMetadataUpsertDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This endpoint requires the `asset.update` permission.
|
||||||
|
*/
|
||||||
|
export function deleteAssetMetadata({ id, key }: {
|
||||||
|
id: string;
|
||||||
|
key: AssetMetadataKey;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, {
|
||||||
|
...opts,
|
||||||
|
method: "DELETE"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This endpoint requires the `asset.read` permission.
|
||||||
|
*/
|
||||||
|
export function getAssetMetadataByKey({ id, key }: {
|
||||||
|
id: string;
|
||||||
|
key: AssetMetadataKey;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: AssetMetadataResponseDto;
|
||||||
|
}>(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This endpoint requires the `asset.download` permission.
|
* This endpoint requires the `asset.download` permission.
|
||||||
*/
|
*/
|
||||||
@@ -4725,6 +4793,9 @@ export enum Permission {
|
|||||||
AdminUserDelete = "adminUser.delete",
|
AdminUserDelete = "adminUser.delete",
|
||||||
AdminAuthUnlinkAll = "adminAuth.unlinkAll"
|
AdminAuthUnlinkAll = "adminAuth.unlinkAll"
|
||||||
}
|
}
|
||||||
|
export enum AssetMetadataKey {
|
||||||
|
MobileApp = "mobile-app"
|
||||||
|
}
|
||||||
export enum AssetMediaStatus {
|
export enum AssetMediaStatus {
|
||||||
Created = "created",
|
Created = "created",
|
||||||
Replaced = "replaced",
|
Replaced = "replaced",
|
||||||
@@ -4811,6 +4882,8 @@ export enum SyncEntityType {
|
|||||||
AssetV1 = "AssetV1",
|
AssetV1 = "AssetV1",
|
||||||
AssetDeleteV1 = "AssetDeleteV1",
|
AssetDeleteV1 = "AssetDeleteV1",
|
||||||
AssetExifV1 = "AssetExifV1",
|
AssetExifV1 = "AssetExifV1",
|
||||||
|
AssetMetadataV1 = "AssetMetadataV1",
|
||||||
|
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
|
||||||
PartnerV1 = "PartnerV1",
|
PartnerV1 = "PartnerV1",
|
||||||
PartnerDeleteV1 = "PartnerDeleteV1",
|
PartnerDeleteV1 = "PartnerDeleteV1",
|
||||||
PartnerAssetV1 = "PartnerAssetV1",
|
PartnerAssetV1 = "PartnerAssetV1",
|
||||||
@@ -4858,6 +4931,7 @@ export enum SyncRequestType {
|
|||||||
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
|
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
|
||||||
AssetsV1 = "AssetsV1",
|
AssetsV1 = "AssetsV1",
|
||||||
AssetExifsV1 = "AssetExifsV1",
|
AssetExifsV1 = "AssetExifsV1",
|
||||||
|
AssetMetadataV1 = "AssetMetadataV1",
|
||||||
AuthUsersV1 = "AuthUsersV1",
|
AuthUsersV1 = "AuthUsersV1",
|
||||||
MemoriesV1 = "MemoriesV1",
|
MemoriesV1 = "MemoriesV1",
|
||||||
MemoryToAssetsV1 = "MemoryToAssetsV1",
|
MemoryToAssetsV1 = "MemoryToAssetsV1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.139.4",
|
"version": "1.140.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AssetController } from 'src/controllers/asset.controller';
|
import { AssetController } from 'src/controllers/asset.controller';
|
||||||
|
import { AssetMetadataKey } from 'src/enum';
|
||||||
import { AssetService } from 'src/services/asset.service';
|
import { AssetService } from 'src/services/asset.service';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
@@ -6,14 +7,16 @@ import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'
|
|||||||
|
|
||||||
describe(AssetController.name, () => {
|
describe(AssetController.name, () => {
|
||||||
let ctx: ControllerContext;
|
let ctx: ControllerContext;
|
||||||
|
const service = mockBaseService(AssetService);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: mockBaseService(AssetService) }]);
|
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: service }]);
|
||||||
return () => ctx.close();
|
return () => ctx.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ctx.reset();
|
ctx.reset();
|
||||||
|
service.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PUT /assets', () => {
|
describe('PUT /assets', () => {
|
||||||
@@ -115,4 +118,120 @@ describe(AssetController.name, () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /assets/:id/metadata', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`);
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /assets/:id/metadata', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] });
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid id', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require items to be an array', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(factory.responses.badRequest(['items must be an array']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require each item to have a valid key', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
|
.put(`/assets/${factory.uuid()}/metadata`)
|
||||||
|
.send({ items: [{ key: 'someKey' }] });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
factory.responses.badRequest(
|
||||||
|
expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require each item to have a value', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
|
.put(`/assets/${factory.uuid()}/metadata`)
|
||||||
|
.send({ items: [{ key: 'mobile-app', value: null }] });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
factory.responses.badRequest(expect.arrayContaining([expect.stringContaining('value must be an object')])),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(AssetMetadataKey.MobileApp, () => {
|
||||||
|
it('should accept valid data and pass to service correctly', async () => {
|
||||||
|
const assetId = factory.uuid();
|
||||||
|
const { status } = await request(ctx.getHttpServer())
|
||||||
|
.put(`/assets/${assetId}/metadata`)
|
||||||
|
.send({ items: [{ key: 'mobile-app', value: { iCloudId: '123' } }] });
|
||||||
|
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
|
||||||
|
items: [{ key: 'mobile-app', value: { iCloudId: '123' } }],
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work without iCloudId', async () => {
|
||||||
|
const assetId = factory.uuid();
|
||||||
|
const { status } = await request(ctx.getHttpServer())
|
||||||
|
.put(`/assets/${assetId}/metadata`)
|
||||||
|
.send({ items: [{ key: 'mobile-app', value: {} }] });
|
||||||
|
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
|
||||||
|
items: [{ key: 'mobile-app', value: {} }],
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /assets/:id/metadata/:key', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`);
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid id', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid key', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
factory.responses.badRequest(
|
||||||
|
expect.arrayContaining([expect.stringContaining('key must be one of the following value')]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /assets/:id/metadata/:key', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`);
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid id', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid key', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
AssetBulkDeleteDto,
|
AssetBulkDeleteDto,
|
||||||
AssetBulkUpdateDto,
|
AssetBulkUpdateDto,
|
||||||
AssetJobsDto,
|
AssetJobsDto,
|
||||||
|
AssetMetadataResponseDto,
|
||||||
|
AssetMetadataRouteParams,
|
||||||
|
AssetMetadataUpsertDto,
|
||||||
AssetStatsDto,
|
AssetStatsDto,
|
||||||
AssetStatsResponseDto,
|
AssetStatsResponseDto,
|
||||||
DeviceIdDto,
|
DeviceIdDto,
|
||||||
@@ -85,4 +88,36 @@ export class AssetController {
|
|||||||
): Promise<AssetResponseDto> {
|
): Promise<AssetResponseDto> {
|
||||||
return this.service.update(auth, id, dto);
|
return this.service.update(auth, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/metadata')
|
||||||
|
@Authenticated({ permission: Permission.AssetRead })
|
||||||
|
getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetMetadataResponseDto[]> {
|
||||||
|
return this.service.getMetadata(auth, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/metadata')
|
||||||
|
@Authenticated({ permission: Permission.AssetUpdate })
|
||||||
|
updateAssetMetadata(
|
||||||
|
@Auth() auth: AuthDto,
|
||||||
|
@Param() { id }: UUIDParamDto,
|
||||||
|
@Body() dto: AssetMetadataUpsertDto,
|
||||||
|
): Promise<AssetMetadataResponseDto[]> {
|
||||||
|
return this.service.upsertMetadata(auth, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/metadata/:key')
|
||||||
|
@Authenticated({ permission: Permission.AssetRead })
|
||||||
|
getAssetMetadataByKey(
|
||||||
|
@Auth() auth: AuthDto,
|
||||||
|
@Param() { id, key }: AssetMetadataRouteParams,
|
||||||
|
): Promise<AssetMetadataResponseDto> {
|
||||||
|
return this.service.getMetadataByKey(auth, id, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/metadata/:key')
|
||||||
|
@Authenticated({ permission: Permission.AssetUpdate })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise<void> {
|
||||||
|
return this.service.deleteMetadataByKey(auth, id, key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||||
|
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
|
||||||
import { AssetVisibility } from 'src/enum';
|
import { AssetVisibility } from 'src/enum';
|
||||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
@@ -64,6 +65,12 @@ export class AssetMediaCreateDto extends AssetMediaBase {
|
|||||||
@ValidateUUID({ optional: true })
|
@ValidateUUID({ optional: true })
|
||||||
livePhotoVideoId?: string;
|
livePhotoVideoId?: string;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AssetMetadataUpsertItemDto)
|
||||||
|
metadata!: AssetMetadataUpsertItemDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||||
[UploadFieldName.SIDECAR_DATA]?: any;
|
[UploadFieldName.SIDECAR_DATA]?: any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsLatitude,
|
IsLatitude,
|
||||||
IsLongitude,
|
IsLongitude,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsObject,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
IsTimeZone,
|
IsTimeZone,
|
||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AssetType, AssetVisibility } from 'src/enum';
|
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
|
||||||
import { AssetStats } from 'src/repositories/asset.repository';
|
import { AssetStats } from 'src/repositories/asset.repository';
|
||||||
|
import { AssetMetadata, AssetMetadataItem } from 'src/types';
|
||||||
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class DeviceIdDto {
|
export class DeviceIdDto {
|
||||||
@@ -135,6 +139,53 @@ export class AssetStatsResponseDto {
|
|||||||
total!: number;
|
total!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AssetMetadataRouteParams {
|
||||||
|
@ValidateUUID()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||||
|
key!: AssetMetadataKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetMetadataUpsertDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AssetMetadataUpsertItemDto)
|
||||||
|
items!: AssetMetadataUpsertItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetMetadataUpsertItemDto implements AssetMetadataItem {
|
||||||
|
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||||
|
key!: AssetMetadataKey;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type((options) => {
|
||||||
|
switch (options?.object.key) {
|
||||||
|
case AssetMetadataKey.MobileApp: {
|
||||||
|
return AssetMetadataMobileAppDto;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return Object;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
value!: AssetMetadata[AssetMetadataKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetMetadataMobileAppDto {
|
||||||
|
@IsString()
|
||||||
|
@Optional()
|
||||||
|
iCloudId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetMetadataResponseDto {
|
||||||
|
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||||
|
key!: AssetMetadataKey;
|
||||||
|
value!: object;
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
||||||
return {
|
return {
|
||||||
images: stats[AssetType.Image],
|
images: stats[AssetType.Image],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
|
|||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||||
import {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
|
AssetMetadataKey,
|
||||||
AssetOrder,
|
AssetOrder,
|
||||||
AssetType,
|
AssetType,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
@@ -162,6 +163,21 @@ export class SyncAssetExifV1 {
|
|||||||
fps!: number | null;
|
fps!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExtraModel()
|
||||||
|
export class SyncAssetMetadataV1 {
|
||||||
|
assetId!: string;
|
||||||
|
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||||
|
key!: AssetMetadataKey;
|
||||||
|
value!: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExtraModel()
|
||||||
|
export class SyncAssetMetadataDeleteV1 {
|
||||||
|
assetId!: string;
|
||||||
|
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||||
|
key!: AssetMetadataKey;
|
||||||
|
}
|
||||||
|
|
||||||
@ExtraModel()
|
@ExtraModel()
|
||||||
export class SyncAlbumDeleteV1 {
|
export class SyncAlbumDeleteV1 {
|
||||||
albumId!: string;
|
albumId!: string;
|
||||||
@@ -328,6 +344,8 @@ export type SyncItem = {
|
|||||||
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
|
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
|
||||||
[SyncEntityType.AssetV1]: SyncAssetV1;
|
[SyncEntityType.AssetV1]: SyncAssetV1;
|
||||||
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
|
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
|
||||||
|
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
|
||||||
|
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
|
||||||
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
||||||
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||||
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
|
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
|
||||||
|
|||||||
@@ -276,6 +276,10 @@ export enum UserMetadataKey {
|
|||||||
Onboarding = 'onboarding',
|
Onboarding = 'onboarding',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AssetMetadataKey {
|
||||||
|
MobileApp = 'mobile-app',
|
||||||
|
}
|
||||||
|
|
||||||
export enum UserAvatarColor {
|
export enum UserAvatarColor {
|
||||||
Primary = 'primary',
|
Primary = 'primary',
|
||||||
Pink = 'pink',
|
Pink = 'pink',
|
||||||
@@ -627,6 +631,7 @@ export enum SyncRequestType {
|
|||||||
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
||||||
AssetsV1 = 'AssetsV1',
|
AssetsV1 = 'AssetsV1',
|
||||||
AssetExifsV1 = 'AssetExifsV1',
|
AssetExifsV1 = 'AssetExifsV1',
|
||||||
|
AssetMetadataV1 = 'AssetMetadataV1',
|
||||||
AuthUsersV1 = 'AuthUsersV1',
|
AuthUsersV1 = 'AuthUsersV1',
|
||||||
MemoriesV1 = 'MemoriesV1',
|
MemoriesV1 = 'MemoriesV1',
|
||||||
MemoryToAssetsV1 = 'MemoryToAssetsV1',
|
MemoryToAssetsV1 = 'MemoryToAssetsV1',
|
||||||
@@ -650,6 +655,8 @@ export enum SyncEntityType {
|
|||||||
AssetV1 = 'AssetV1',
|
AssetV1 = 'AssetV1',
|
||||||
AssetDeleteV1 = 'AssetDeleteV1',
|
AssetDeleteV1 = 'AssetDeleteV1',
|
||||||
AssetExifV1 = 'AssetExifV1',
|
AssetExifV1 = 'AssetExifV1',
|
||||||
|
AssetMetadataV1 = 'AssetMetadataV1',
|
||||||
|
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
|
||||||
|
|
||||||
PartnerV1 = 'PartnerV1',
|
PartnerV1 = 'PartnerV1',
|
||||||
PartnerDeleteV1 = 'PartnerDeleteV1',
|
PartnerDeleteV1 = 'PartnerDeleteV1',
|
||||||
|
|||||||
@@ -468,9 +468,8 @@ where
|
|||||||
"asset"."visibility" != $1
|
"asset"."visibility" != $1
|
||||||
and "asset"."deletedAt" is null
|
and "asset"."deletedAt" is null
|
||||||
and "job_status"."previewAt" is not null
|
and "job_status"."previewAt" is not null
|
||||||
and "job_status"."facesRecognizedAt" is null
|
|
||||||
order by
|
order by
|
||||||
"asset"."createdAt" desc
|
"asset"."fileCreatedAt" desc
|
||||||
|
|
||||||
-- AssetJobRepository.streamForMigrationJob
|
-- AssetJobRepository.streamForMigrationJob
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -19,6 +19,33 @@ returning
|
|||||||
"dateTimeOriginal",
|
"dateTimeOriginal",
|
||||||
"timeZone"
|
"timeZone"
|
||||||
|
|
||||||
|
-- AssetRepository.getMetadata
|
||||||
|
select
|
||||||
|
"key",
|
||||||
|
"value",
|
||||||
|
"updatedAt"
|
||||||
|
from
|
||||||
|
"asset_metadata"
|
||||||
|
where
|
||||||
|
"assetId" = $1
|
||||||
|
|
||||||
|
-- AssetRepository.getMetadataByKey
|
||||||
|
select
|
||||||
|
"key",
|
||||||
|
"value",
|
||||||
|
"updatedAt"
|
||||||
|
from
|
||||||
|
"asset_metadata"
|
||||||
|
where
|
||||||
|
"assetId" = $1
|
||||||
|
and "key" = $2
|
||||||
|
|
||||||
|
-- AssetRepository.deleteMetadataByKey
|
||||||
|
delete from "asset_metadata"
|
||||||
|
where
|
||||||
|
"assetId" = $1
|
||||||
|
and "key" = $2
|
||||||
|
|
||||||
-- AssetRepository.getByDayOfYear
|
-- AssetRepository.getByDayOfYear
|
||||||
with
|
with
|
||||||
"res" as (
|
"res" as (
|
||||||
|
|||||||
@@ -539,6 +539,37 @@ where
|
|||||||
order by
|
order by
|
||||||
"asset_face"."updateId" asc
|
"asset_face"."updateId" asc
|
||||||
|
|
||||||
|
-- SyncRepository.assetMetadata.getDeletes
|
||||||
|
select
|
||||||
|
"asset_metadata_audit"."id",
|
||||||
|
"assetId",
|
||||||
|
"key"
|
||||||
|
from
|
||||||
|
"asset_metadata_audit" as "asset_metadata_audit"
|
||||||
|
left join "asset" on "asset"."id" = "asset_metadata_audit"."assetId"
|
||||||
|
where
|
||||||
|
"asset_metadata_audit"."id" < $1
|
||||||
|
and "asset_metadata_audit"."id" > $2
|
||||||
|
and "asset"."ownerId" = $3
|
||||||
|
order by
|
||||||
|
"asset_metadata_audit"."id" asc
|
||||||
|
|
||||||
|
-- SyncRepository.assetMetadata.getUpserts
|
||||||
|
select
|
||||||
|
"assetId",
|
||||||
|
"key",
|
||||||
|
"value",
|
||||||
|
"asset_metadata"."updateId"
|
||||||
|
from
|
||||||
|
"asset_metadata" as "asset_metadata"
|
||||||
|
inner join "asset" on "asset"."id" = "asset_metadata"."assetId"
|
||||||
|
where
|
||||||
|
"asset_metadata"."updateId" < $1
|
||||||
|
and "asset_metadata"."updateId" > $2
|
||||||
|
and "asset"."ownerId" = $3
|
||||||
|
order by
|
||||||
|
"asset_metadata"."updateId" asc
|
||||||
|
|
||||||
-- SyncRepository.authUser.getUpserts
|
-- SyncRepository.authUser.getUpserts
|
||||||
select
|
select
|
||||||
"id",
|
"id",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ where
|
|||||||
and "fileCreatedAt" is not null
|
and "fileCreatedAt" is not null
|
||||||
and "fileModifiedAt" is not null
|
and "fileModifiedAt" is not null
|
||||||
and "localDateTime" is not null
|
and "localDateTime" is not null
|
||||||
|
order by
|
||||||
|
"directoryPath" asc
|
||||||
|
|
||||||
-- ViewRepository.getAssetsByOriginalPath
|
-- ViewRepository.getAssetsByOriginalPath
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -334,9 +334,9 @@ export class AssetJobRepository {
|
|||||||
@GenerateSql({ params: [], stream: true })
|
@GenerateSql({ params: [], stream: true })
|
||||||
streamForDetectFacesJob(force?: boolean) {
|
streamForDetectFacesJob(force?: boolean) {
|
||||||
return this.assetsWithPreviews()
|
return this.assetsWithPreviews()
|
||||||
.$if(!force, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
|
.$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
|
||||||
.select(['asset.id'])
|
.select(['asset.id'])
|
||||||
.orderBy('asset.createdAt', 'desc')
|
.orderBy('asset.fileCreatedAt', 'desc')
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely';
|
import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
|
||||||
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Stack } from 'src/database';
|
import { Stack } from 'src/database';
|
||||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
|
import { AssetMetadataItem } from 'src/types';
|
||||||
import {
|
import {
|
||||||
anyUuid,
|
anyUuid,
|
||||||
asUuid,
|
asUuid,
|
||||||
@@ -210,6 +211,43 @@ export class AssetRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
getMetadata(assetId: string) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('asset_metadata')
|
||||||
|
.select(['key', 'value', 'updatedAt'])
|
||||||
|
.where('assetId', '=', assetId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertMetadata(id: string, items: AssetMetadataItem[]) {
|
||||||
|
return this.db
|
||||||
|
.insertInto('asset_metadata')
|
||||||
|
.values(items.map((item) => ({ assetId: id, ...item })))
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.columns(['assetId', 'key'])
|
||||||
|
.doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })),
|
||||||
|
)
|
||||||
|
.returning(['key', 'value', 'updatedAt'])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
|
getMetadataByKey(assetId: string, key: AssetMetadataKey) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('asset_metadata')
|
||||||
|
.select(['key', 'value', 'updatedAt'])
|
||||||
|
.where('assetId', '=', assetId)
|
||||||
|
.where('key', '=', key)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
|
async deleteMetadataByKey(id: string, key: AssetMetadataKey) {
|
||||||
|
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
|
||||||
|
}
|
||||||
|
|
||||||
create(asset: Insertable<AssetTable>) {
|
create(asset: Insertable<AssetTable>) {
|
||||||
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
|
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class SyncRepository {
|
|||||||
asset: AssetSync;
|
asset: AssetSync;
|
||||||
assetExif: AssetExifSync;
|
assetExif: AssetExifSync;
|
||||||
assetFace: AssetFaceSync;
|
assetFace: AssetFaceSync;
|
||||||
|
assetMetadata: AssetMetadataSync;
|
||||||
authUser: AuthUserSync;
|
authUser: AuthUserSync;
|
||||||
memory: MemorySync;
|
memory: MemorySync;
|
||||||
memoryToAsset: MemoryToAssetSync;
|
memoryToAsset: MemoryToAssetSync;
|
||||||
@@ -75,6 +76,7 @@ export class SyncRepository {
|
|||||||
this.asset = new AssetSync(this.db);
|
this.asset = new AssetSync(this.db);
|
||||||
this.assetExif = new AssetExifSync(this.db);
|
this.assetExif = new AssetExifSync(this.db);
|
||||||
this.assetFace = new AssetFaceSync(this.db);
|
this.assetFace = new AssetFaceSync(this.db);
|
||||||
|
this.assetMetadata = new AssetMetadataSync(this.db);
|
||||||
this.authUser = new AuthUserSync(this.db);
|
this.authUser = new AuthUserSync(this.db);
|
||||||
this.memory = new MemorySync(this.db);
|
this.memory = new MemorySync(this.db);
|
||||||
this.memoryToAsset = new MemoryToAssetSync(this.db);
|
this.memoryToAsset = new MemoryToAssetSync(this.db);
|
||||||
@@ -685,3 +687,23 @@ class UserMetadataSync extends BaseSync {
|
|||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AssetMetadataSync extends BaseSync {
|
||||||
|
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
|
||||||
|
getDeletes(options: SyncQueryOptions, userId: string) {
|
||||||
|
return this.auditQuery('asset_metadata_audit', options)
|
||||||
|
.select(['asset_metadata_audit.id', 'assetId', 'key'])
|
||||||
|
.leftJoin('asset', 'asset.id', 'asset_metadata_audit.assetId')
|
||||||
|
.where('asset.ownerId', '=', userId)
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
|
||||||
|
getUpserts(options: SyncQueryOptions, userId: string) {
|
||||||
|
return this.upsertQuery('asset_metadata', options)
|
||||||
|
.select(['assetId', 'key', 'value', 'asset_metadata.updateId'])
|
||||||
|
.innerJoin('asset', 'asset.id', 'asset_metadata.assetId')
|
||||||
|
.where('asset.ownerId', '=', userId)
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user