mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge branch 'main' into album-delete-sync
This commit is contained in:
commit
9301f029d2
196 changed files with 6256 additions and 887 deletions
|
|
@ -5,7 +5,8 @@
|
||||||
"immich-server",
|
"immich-server",
|
||||||
"redis",
|
"redis",
|
||||||
"database",
|
"database",
|
||||||
"immich-machine-learning"
|
"immich-machine-learning",
|
||||||
|
"init"
|
||||||
],
|
],
|
||||||
"dockerComposeFile": [
|
"dockerComposeFile": [
|
||||||
"../docker/docker-compose.dev.yml",
|
"../docker/docker-compose.dev.yml",
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,22 @@ services:
|
||||||
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||||
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
immich-web:
|
immich-web:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
|
init:
|
||||||
|
env_file: !reset []
|
||||||
|
command: sh -c 'find /data -maxdepth 1 ! -path "/data/postgres" -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
database:
|
database:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ run_cmd pnpm --filter immich install
|
||||||
log "Starting Nest API Server"
|
log "Starting Nest API Server"
|
||||||
log ""
|
log ""
|
||||||
cd "${IMMICH_WORKSPACE}/server" || (
|
cd "${IMMICH_WORKSPACE}/server" || (
|
||||||
log "Immich workspace not found"jj
|
log "Immich workspace not found"
|
||||||
exit 1
|
exit 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -4,34 +4,13 @@ module.exports = {
|
||||||
if (!pkg.name) {
|
if (!pkg.name) {
|
||||||
return pkg;
|
return pkg;
|
||||||
}
|
}
|
||||||
switch (pkg.name) {
|
if (pkg.name === "exiftool-vendored") {
|
||||||
case "exiftool-vendored":
|
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
|
||||||
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
|
// make exiftool-vendored.pl a regular dependency
|
||||||
// make exiftool-vendored.pl a regular dependency
|
pkg.dependencies["exiftool-vendored.pl"] =
|
||||||
pkg.dependencies["exiftool-vendored.pl"] =
|
pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||||
pkg.optionalDependencies["exiftool-vendored.pl"];
|
delete pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||||
delete pkg.optionalDependencies["exiftool-vendored.pl"];
|
}
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "sharp":
|
|
||||||
const optionalDeps = Object.keys(pkg.optionalDependencies).filter(
|
|
||||||
(dep) => dep.startsWith("@img")
|
|
||||||
);
|
|
||||||
for (const dep of optionalDeps) {
|
|
||||||
// remove all optionalDependencies from sharp (they will be compiled from source), except:
|
|
||||||
// include the precompiled musl version of sharp, for web
|
|
||||||
// include precompiled linux-x64 version of sharp, for server (stage: web-prod)
|
|
||||||
// include precompiled linux-arm64 version of sharp, for server (stage: web-prod)
|
|
||||||
if (
|
|
||||||
dep.includes("musl") ||
|
|
||||||
dep.includes("linux-x64") ||
|
|
||||||
dep.includes("linux-arm64")
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
delete pkg.optionalDependencies[dep];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return pkg;
|
return pkg;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
70
Makefile
70
Makefile
|
|
@ -1,29 +1,29 @@
|
||||||
dev:
|
dev: prepare-volumes
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||||
|
|
||||||
dev-down:
|
dev-down:
|
||||||
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
||||||
|
|
||||||
dev-update:
|
dev-update: prepare-volumes
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
dev-scale:
|
dev-scale: prepare-volumes
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||||
|
|
||||||
dev-docs:
|
dev-docs: prepare-volumes
|
||||||
npm --prefix docs run start
|
npm --prefix docs run start
|
||||||
|
|
||||||
.PHONY: e2e
|
.PHONY: e2e
|
||||||
e2e:
|
e2e: prepare-volumes
|
||||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||||
|
|
||||||
e2e-update:
|
e2e-update: prepare-volumes
|
||||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
e2e-down:
|
e2e-down:
|
||||||
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
|
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
|
||||||
|
|
||||||
prod:
|
prod:
|
||||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
prod-down:
|
prod-down:
|
||||||
|
|
@ -33,16 +33,16 @@ prod-scale:
|
||||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||||
|
|
||||||
.PHONY: open-api
|
.PHONY: open-api
|
||||||
open-api:
|
open-api: prepare-volumes
|
||||||
cd ./open-api && bash ./bin/generate-open-api.sh
|
cd ./open-api && bash ./bin/generate-open-api.sh
|
||||||
|
|
||||||
open-api-dart:
|
open-api-dart: prepare-volumes
|
||||||
cd ./open-api && bash ./bin/generate-open-api.sh dart
|
cd ./open-api && bash ./bin/generate-open-api.sh dart
|
||||||
|
|
||||||
open-api-typescript:
|
open-api-typescript: prepare-volumes
|
||||||
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
||||||
|
|
||||||
sql:
|
sql: prepare-volumes
|
||||||
pnpm --filter immich run sync:sql
|
pnpm --filter immich run sync:sql
|
||||||
|
|
||||||
attach-server:
|
attach-server:
|
||||||
|
|
@ -51,6 +51,47 @@ attach-server:
|
||||||
renovate:
|
renovate:
|
||||||
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
||||||
|
|
||||||
|
# Directories that need to be created for volumes or build output
|
||||||
|
VOLUME_DIRS = \
|
||||||
|
./.pnpm-store \
|
||||||
|
./web/.svelte-kit \
|
||||||
|
./web/node_modules \
|
||||||
|
./web/coverage \
|
||||||
|
./e2e/node_modules \
|
||||||
|
./docs/node_modules \
|
||||||
|
./server/node_modules \
|
||||||
|
./open-api/typescript-sdk/node_modules \
|
||||||
|
./.github/node_modules \
|
||||||
|
./node_modules \
|
||||||
|
./cli/node_modules
|
||||||
|
|
||||||
|
# Include .env file if it exists
|
||||||
|
-include docker/.env
|
||||||
|
|
||||||
|
# Helper function to chown, on error suggest remediation and exit
|
||||||
|
define safe_chown
|
||||||
|
if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \
|
||||||
|
true; \
|
||||||
|
else \
|
||||||
|
echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \
|
||||||
|
exit 1; \
|
||||||
|
fi;
|
||||||
|
endef
|
||||||
|
# create empty directories and chown
|
||||||
|
prepare-volumes:
|
||||||
|
@$(foreach dir,$(VOLUME_DIRS),mkdir -p $(dir);)
|
||||||
|
@$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R))
|
||||||
|
ifneq ($(UPLOAD_LOCATION),)
|
||||||
|
ifeq ($(filter /%,$(UPLOAD_LOCATION)),)
|
||||||
|
@mkdir -p "docker/$(UPLOAD_LOCATION)"
|
||||||
|
@$(call safe_chown,docker/$(UPLOAD_LOCATION),)
|
||||||
|
else
|
||||||
|
@mkdir -p "$(UPLOAD_LOCATION)"
|
||||||
|
@$(call safe_chown,$(UPLOAD_LOCATION),)
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
|
||||||
MODULES = e2e server web cli sdk docs .github
|
MODULES = e2e server web cli sdk docs .github
|
||||||
|
|
||||||
# directory to package name mapping function
|
# directory to package name mapping function
|
||||||
|
|
@ -126,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.82",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,17 @@ services:
|
||||||
- ${UPLOAD_LOCATION}/photos:/data
|
- ${UPLOAD_LOCATION}/photos:/data
|
||||||
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -84,6 +95,17 @@ services:
|
||||||
- 24678:24678
|
- 24678:24678
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/usr/src/app
|
- ..:/usr/src/app
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 1048576
|
soft: 1048576
|
||||||
|
|
@ -163,13 +185,35 @@ 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/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
command: sh -c 'find /data -maxdepth 1 -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
||||||
|
volumes:
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
|
pnpm-store:
|
||||||
|
server-node_modules:
|
||||||
|
web-node_modules:
|
||||||
|
github-node_modules:
|
||||||
|
cli-node_modules:
|
||||||
|
docs-node_modules:
|
||||||
|
e2e-node_modules:
|
||||||
|
sdk-node_modules:
|
||||||
|
app-node_modules:
|
||||||
|
sveltekit:
|
||||||
|
coverage:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,31 @@
|
||||||
# FAQ
|
# FAQ
|
||||||
|
|
||||||
|
## Commercial Guidelines
|
||||||
|
|
||||||
|
### Are you open to commercial partnerships and collaborations?
|
||||||
|
|
||||||
|
We are working to commercialize Immich and we'd love for you to help us by making Immich better. FUTO is dedicated to developing sustainable models for developing open source software for our customers. We want our customers to be delighted by the products our engineers deliver, and we want our engineers to be paid when they succeed.
|
||||||
|
|
||||||
|
If you wish to use Immich in a commercial product not owned by FUTO, we have the following requirements:
|
||||||
|
|
||||||
|
- Plugin Integrations: Integrations for other platforms are typically approved, provided proper notification is given.
|
||||||
|
|
||||||
|
- Reseller Partnerships: Must adhere to the guidelines outlined below regarding trademark usage, and proper representation.
|
||||||
|
|
||||||
|
- Strategic Collaborations: We welcome discussions about mutually beneficial partnerships that enhance the value proposition for both organizations.
|
||||||
|
|
||||||
|
### What are your guidelines for resellers and trademark usage?
|
||||||
|
|
||||||
|
For organizations seeking to resell Immich, we have established the following guidelines to protect our brand integrity and ensure proper representation.
|
||||||
|
|
||||||
|
- We request that resellers do not display our trademarks on their websites or marketing materials. If such usage is discovered, we will contact you to request removal.
|
||||||
|
|
||||||
|
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
|
||||||
|
|
||||||
|
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directy from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
|
||||||
|
|
||||||
|
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
|
||||||
|
|
||||||
## User
|
## User
|
||||||
|
|
||||||
### How can I reset the admin password?
|
### How can I reset the admin password?
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Unable to set `app.immich:///oauth-callback` as a valid redirect URI? See [Mobil
|
||||||
|
|
||||||
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
||||||
|
|
||||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
- [Authentik](https://integrations.goauthentik.io/media/immich/)
|
||||||
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
|
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
|
||||||
- [Okta](https://www.okta.com/openid-connect/)
|
- [Okta](https://www.okta.com/openid-connect/)
|
||||||
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
||||||
|
|
@ -88,7 +88,7 @@ The `.well-known/openid-configuration` part of the url is optional and will be a
|
||||||
## Auto Launch
|
## Auto Launch
|
||||||
|
|
||||||
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
|
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
|
||||||
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?authLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
|
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?autoLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
|
||||||
|
|
||||||
## Mobile Redirect URI
|
## Mobile Redirect URI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
12
docs/static/archived-versions.json
vendored
12
docs/static/archived-versions.json
vendored
|
|
@ -1,4 +1,16 @@
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v1.140.0",
|
||||||
|
"url": "https://v1.140.0.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v1.139.4",
|
||||||
|
"url": "https://v1.139.4.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v1.139.3",
|
||||||
|
"url": "https://v1.139.3.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v1.139.2",
|
"label": "v1.139.2",
|
||||||
"url": "https://v1.139.2.archive.immich.app"
|
"url": "https://v1.139.2.archive.immich.app"
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ services:
|
||||||
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
|
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:0e763a2383d56f90364fcd72767ac41400cd30d2627f407f7e7960c9f1923c21
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b
|
||||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.139.2",
|
"version": "1.140.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
22
i18n/en.json
22
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",
|
||||||
|
|
@ -500,7 +503,7 @@
|
||||||
"assets": "Assets",
|
"assets": "Assets",
|
||||||
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
||||||
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
||||||
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal} albums",
|
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
|
||||||
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
||||||
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
||||||
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
||||||
|
|
@ -1073,12 +1076,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 +1271,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 +1518,7 @@
|
||||||
"profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.",
|
"profile_drawer_client_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 +1564,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 +1735,7 @@
|
||||||
"select_user_for_sharing_page_err_album": "Failed to create album",
|
"select_user_for_sharing_page_err_album": "Failed to create album",
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"selected_count": "{count, plural, other {# selected}}",
|
"selected_count": "{count, plural, other {# selected}}",
|
||||||
|
"selected_gps_coordinates": "selected gps coordinates",
|
||||||
"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 +1846,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",
|
||||||
|
|
@ -1941,7 +1957,9 @@
|
||||||
"to_change_password": "Change password",
|
"to_change_password": "Change password",
|
||||||
"to_favorite": "Favorite",
|
"to_favorite": "Favorite",
|
||||||
"to_login": "Login",
|
"to_login": "Login",
|
||||||
|
"to_multi_select": "to multi-select",
|
||||||
"to_parent": "Go to parent",
|
"to_parent": "Go to parent",
|
||||||
|
"to_select": "to select",
|
||||||
"to_trash": "Trash",
|
"to_trash": "Trash",
|
||||||
"toggle_settings": "Toggle settings",
|
"toggle_settings": "Toggle settings",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
|
@ -1991,6 +2009,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",
|
||||||
|
|
@ -2015,6 +2034,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" => 3007,
|
"android.injected.version.code" => 3010,
|
||||||
"android.injected.version.name" => "1.139.2",
|
"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')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
|
@ -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; };
|
||||||
|
|
@ -235,6 +241,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 */,
|
||||||
|
|
@ -252,6 +259,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 = (
|
||||||
|
|
@ -488,10 +505,14 @@
|
||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
|
|
@ -520,10 +541,14 @@
|
||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
|
@ -538,10 +563,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;
|
||||||
|
|
@ -667,7 +695,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 = 215;
|
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;
|
||||||
|
|
@ -811,7 +839,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 = 215;
|
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;
|
||||||
|
|
@ -841,7 +869,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 = 215;
|
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;
|
||||||
|
|
@ -875,7 +903,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 = 215;
|
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;
|
||||||
|
|
@ -918,7 +946,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 = 215;
|
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;
|
||||||
|
|
@ -958,7 +986,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 = 215;
|
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;
|
||||||
|
|
@ -997,7 +1025,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 = 215;
|
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;
|
||||||
|
|
@ -1041,7 +1069,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 = 215;
|
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;
|
||||||
|
|
@ -1082,7 +1110,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 = 215;
|
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>
|
||||||
|
|
@ -78,7 +81,7 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.138.1</string>
|
<string>1.139.3</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|
@ -105,7 +108,7 @@
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>215</string>
|
<string>217</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true />
|
<true />
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|
@ -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.2"
|
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,6 +67,9 @@ 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),
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ profileChangedAt: $profileChangedAt
|
||||||
bool? isPartnerSharedWith,
|
bool? isPartnerSharedWith,
|
||||||
bool? hasProfileImage,
|
bool? hasProfileImage,
|
||||||
DateTime? profileChangedAt,
|
DateTime? profileChangedAt,
|
||||||
|
int? quotaSizeInBytes,
|
||||||
|
int? quotaUsageInBytes,
|
||||||
}) => UserDto(
|
}) => UserDto(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
email: email ?? this.email,
|
email: email ?? this.email,
|
||||||
|
|
@ -88,6 +90,8 @@ profileChangedAt: $profileChangedAt
|
||||||
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
|
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
|
||||||
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
|
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
|
||||||
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
|
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
|
||||||
|
quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes,
|
||||||
|
quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -105,7 +109,9 @@ profileChangedAt: $profileChangedAt
|
||||||
other.memoryEnabled == memoryEnabled &&
|
other.memoryEnabled == memoryEnabled &&
|
||||||
other.inTimeline == inTimeline &&
|
other.inTimeline == inTimeline &&
|
||||||
other.hasProfileImage == hasProfileImage &&
|
other.hasProfileImage == hasProfileImage &&
|
||||||
other.profileChangedAt.isAtSameMomentAs(profileChangedAt);
|
other.profileChangedAt.isAtSameMomentAs(profileChangedAt) &&
|
||||||
|
other.quotaSizeInBytes == quotaSizeInBytes &&
|
||||||
|
other.quotaUsageInBytes == quotaUsageInBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -121,7 +127,9 @@ profileChangedAt: $profileChangedAt
|
||||||
isPartnerSharedBy.hashCode ^
|
isPartnerSharedBy.hashCode ^
|
||||||
isPartnerSharedWith.hashCode ^
|
isPartnerSharedWith.hashCode ^
|
||||||
hasProfileImage.hashCode ^
|
hasProfileImage.hashCode ^
|
||||||
profileChangedAt.hashCode;
|
profileChangedAt.hashCode ^
|
||||||
|
quotaSizeInBytes.hashCode ^
|
||||||
|
quotaUsageInBytes.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PartnerUserDto {
|
class PartnerUserDto {
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,6 @@ isOnboarded: $isOnboarded,
|
||||||
int get hashCode => isOnboarded.hashCode;
|
int get hashCode => isOnboarded.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: wait to be overwritten
|
|
||||||
class Preferences {
|
class Preferences {
|
||||||
final bool foldersEnabled;
|
final bool foldersEnabled;
|
||||||
final bool memoriesEnabled;
|
final bool memoriesEnabled;
|
||||||
|
|
@ -133,17 +132,17 @@ class Preferences {
|
||||||
|
|
||||||
factory Preferences.fromMap(Map<String, Object?> map) {
|
factory Preferences.fromMap(Map<String, Object?> map) {
|
||||||
return Preferences(
|
return Preferences(
|
||||||
foldersEnabled: map["folders-Enabled"] as bool? ?? false,
|
foldersEnabled: (map["folders"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
|
||||||
memoriesEnabled: map["memories-Enabled"] as bool? ?? true,
|
memoriesEnabled: (map["memories"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
|
||||||
peopleEnabled: map["people-Enabled"] as bool? ?? true,
|
peopleEnabled: (map["people"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
|
||||||
ratingsEnabled: map["ratings-Enabled"] as bool? ?? false,
|
ratingsEnabled: (map["ratings"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
|
||||||
sharedLinksEnabled: map["sharedLinks-Enabled"] as bool? ?? true,
|
sharedLinksEnabled: (map["sharedLinks"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
|
||||||
tagsEnabled: map["tags-Enabled"] as bool? ?? false,
|
tagsEnabled: (map["tags"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
|
||||||
userAvatarColor: AvatarColor.values.firstWhere(
|
userAvatarColor: AvatarColor.values.firstWhere(
|
||||||
(e) => e.value == map["avatar-Color"] as String?,
|
(e) => e.value == (map["avatar"] as Map<String, Object?>?)?["color"] as String?,
|
||||||
orElse: () => AvatarColor.primary,
|
orElse: () => AvatarColor.primary,
|
||||||
),
|
),
|
||||||
showSupportBadge: map["purchase-ShowSupportBadge"] as bool? ?? true,
|
showSupportBadge: (map["purchase"] as Map<String, Object?>?)?["showSupportBadge"] as bool? ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +212,7 @@ class License {
|
||||||
|
|
||||||
factory License.fromMap(Map<String, Object?> map) {
|
factory License.fromMap(Map<String, Object?> map) {
|
||||||
return License(
|
return License(
|
||||||
activatedAt: map["activatedAt"] as DateTime,
|
activatedAt: DateTime.parse(map["activatedAt"] as String),
|
||||||
activationKey: map["activationKey"] as String,
|
activationKey: map["activationKey"] as String,
|
||||||
licenseKey: map["licenseKey"] as String,
|
licenseKey: map["licenseKey"] as String,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,14 @@ class AssetService {
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_platform = const LocalPlatform();
|
_platform = const LocalPlatform();
|
||||||
|
|
||||||
|
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||||
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||||
|
return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||||
return asset is LocalAsset ? _localAssetRepository.watchAsset(id) : _remoteAssetRepository.watchAsset(id);
|
return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RemoteAsset?> getRemoteAsset(String id) {
|
Future<RemoteAsset?> getRemoteAsset(String 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) {
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ class User {
|
||||||
avatarColor: dto.avatarColor,
|
avatarColor: dto.avatarColor,
|
||||||
memoryEnabled: dto.memoryEnabled,
|
memoryEnabled: dto.memoryEnabled,
|
||||||
inTimeline: dto.inTimeline,
|
inTimeline: dto.inTimeline,
|
||||||
|
quotaUsageInBytes: dto.quotaUsageInBytes,
|
||||||
|
quotaSizeInBytes: dto.quotaSizeInBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
UserDto toDto() => UserDto(
|
UserDto toDto() => UserDto(
|
||||||
|
|
|
||||||
|
|
@ -64,19 +64,62 @@ class RemoteImageRequest extends ImageRequest {
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final bytes = Uint8List(response.contentLength);
|
|
||||||
int offset = 0;
|
final cacheManager = this.cacheManager;
|
||||||
final subscription = response.listen((List<int> chunk) {
|
final streamController = StreamController<List<int>>(sync: true);
|
||||||
// this is important to break the response stream if the request is cancelled
|
final Stream<List<int>> stream;
|
||||||
|
cacheManager?.putStreamedFile(url, streamController.stream);
|
||||||
|
stream = response.map((chunk) {
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
throw StateError('Cancelled request');
|
throw StateError('Cancelled request');
|
||||||
}
|
}
|
||||||
bytes.setAll(offset, chunk);
|
if (cacheManager != null) {
|
||||||
offset += chunk.length;
|
streamController.add(chunk);
|
||||||
}, cancelOnError: true);
|
}
|
||||||
cacheManager?.putStreamedFile(url, response);
|
return chunk;
|
||||||
await subscription.asFuture();
|
});
|
||||||
return await ImmutableBuffer.fromUint8List(bytes);
|
|
||||||
|
try {
|
||||||
|
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
|
||||||
|
streamController.close();
|
||||||
|
return await ImmutableBuffer.fromUint8List(bytes);
|
||||||
|
} catch (e) {
|
||||||
|
streamController.addError(e);
|
||||||
|
streamController.close();
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _downloadBytes(Stream<List<int>> stream, int length) async {
|
||||||
|
final Uint8List bytes;
|
||||||
|
int offset = 0;
|
||||||
|
if (length > 0) {
|
||||||
|
// Known content length - use pre-allocated buffer
|
||||||
|
bytes = Uint8List(length);
|
||||||
|
await stream.listen((chunk) {
|
||||||
|
bytes.setAll(offset, chunk);
|
||||||
|
offset += chunk.length;
|
||||||
|
}, cancelOnError: true).asFuture();
|
||||||
|
} else {
|
||||||
|
// Unknown content length - collect chunks dynamically
|
||||||
|
final chunks = <List<int>>[];
|
||||||
|
int totalLength = 0;
|
||||||
|
await stream.listen((chunk) {
|
||||||
|
chunks.add(chunk);
|
||||||
|
totalLength += chunk.length;
|
||||||
|
}, cancelOnError: true).asFuture();
|
||||||
|
|
||||||
|
bytes = Uint8List(totalLength);
|
||||||
|
for (final chunk in chunks) {
|
||||||
|
bytes.setAll(offset, chunk);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ImageInfo?> _loadCachedFile(
|
Future<ImageInfo?> _loadCachedFile(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftLocalAssetRepository(this._db) : super(_db);
|
const DriftLocalAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
Stream<LocalAsset?> watchAsset(String id) {
|
SingleOrNullSelectable<LocalAsset?> _assetSelectable(String id) {
|
||||||
final query = _db.localAssetEntity.select().addColumns([_db.remoteAssetEntity.id]).join([
|
final query = _db.localAssetEntity.select().addColumns([_db.remoteAssetEntity.id]).join([
|
||||||
leftOuterJoin(
|
leftOuterJoin(
|
||||||
_db.remoteAssetEntity,
|
_db.remoteAssetEntity,
|
||||||
|
|
@ -21,9 +21,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||||
return query.map((row) {
|
return query.map((row) {
|
||||||
final asset = row.readTable(_db.localAssetEntity).toDto();
|
final asset = row.readTable(_db.localAssetEntity).toDto();
|
||||||
return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id));
|
return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id));
|
||||||
}).watchSingleOrNull();
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<LocalAsset?> get(String id) => _assetSelectable(id).getSingleOrNull();
|
||||||
|
|
||||||
|
Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
|
||||||
|
|
||||||
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
|
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
|
||||||
if (hashes.isEmpty) {
|
if (hashes.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
final query =
|
final query =
|
||||||
_db.select(_db.memoryEntity).join([
|
_db.select(_db.memoryEntity).join([
|
||||||
leftOuterJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
|
innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
|
||||||
leftOuterJoin(
|
innerJoin(
|
||||||
_db.remoteAssetEntity,
|
_db.remoteAssetEntity,
|
||||||
_db.remoteAssetEntity.id.equalsExp(_db.memoryAssetEntity.assetId) &
|
_db.remoteAssetEntity.id.equalsExp(_db.memoryAssetEntity.assetId) &
|
||||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||||
const DriftRemoteAlbumRepository(this._db) : super(_db);
|
const DriftRemoteAlbumRepository(this._db) : super(_db);
|
||||||
|
|
||||||
Future<List<RemoteAlbum>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {SortRemoteAlbumsBy.updatedAt}}) {
|
Future<List<RemoteAlbum>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {SortRemoteAlbumsBy.updatedAt}}) {
|
||||||
final assetCount = _db.remoteAlbumAssetEntity.assetId.count();
|
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
|
||||||
|
|
||||||
final query = _db.remoteAlbumEntity.select().join([
|
final query = _db.remoteAlbumEntity.select().join([
|
||||||
leftOuterJoin(
|
leftOuterJoin(
|
||||||
|
|
@ -41,7 +41,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||||
..where(_db.remoteAssetEntity.deletedAt.isNull())
|
..where(_db.remoteAssetEntity.deletedAt.isNull())
|
||||||
..addColumns([assetCount])
|
..addColumns([assetCount])
|
||||||
..addColumns([_db.userEntity.name])
|
..addColumns([_db.userEntity.name])
|
||||||
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
|
||||||
..groupBy([_db.remoteAlbumEntity.id]);
|
..groupBy([_db.remoteAlbumEntity.id]);
|
||||||
|
|
||||||
if (sortBy.isNotEmpty) {
|
if (sortBy.isNotEmpty) {
|
||||||
|
|
@ -62,14 +62,14 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||||
.toDto(
|
.toDto(
|
||||||
assetCount: row.read(assetCount) ?? 0,
|
assetCount: row.read(assetCount) ?? 0,
|
||||||
ownerName: row.read(_db.userEntity.name)!,
|
ownerName: row.read(_db.userEntity.name)!,
|
||||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RemoteAlbum?> get(String albumId) {
|
Future<RemoteAlbum?> get(String albumId) {
|
||||||
final assetCount = _db.remoteAlbumAssetEntity.assetId.count();
|
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
|
||||||
|
|
||||||
final query =
|
final query =
|
||||||
_db.remoteAlbumEntity.select().join([
|
_db.remoteAlbumEntity.select().join([
|
||||||
|
|
@ -97,7 +97,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||||
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
|
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
|
||||||
..addColumns([assetCount])
|
..addColumns([assetCount])
|
||||||
..addColumns([_db.userEntity.name])
|
..addColumns([_db.userEntity.name])
|
||||||
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
|
||||||
..groupBy([_db.remoteAlbumEntity.id]);
|
..groupBy([_db.remoteAlbumEntity.id]);
|
||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
@ -107,7 +107,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||||
.toDto(
|
.toDto(
|
||||||
assetCount: row.read(assetCount) ?? 0,
|
assetCount: row.read(assetCount) ?? 0,
|
||||||
ownerName: row.read(_db.userEntity.name)!,
|
ownerName: row.read(_db.userEntity.name)!,
|
||||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
|
|
@ -282,7 +282,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||||
])
|
])
|
||||||
..where(_db.remoteAlbumEntity.id.equals(albumId))
|
..where(_db.remoteAlbumEntity.id.equals(albumId))
|
||||||
..addColumns([_db.userEntity.name])
|
..addColumns([_db.userEntity.name])
|
||||||
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
|
||||||
..groupBy([_db.remoteAlbumEntity.id]);
|
..groupBy([_db.remoteAlbumEntity.id]);
|
||||||
|
|
||||||
return query.map((row) {
|
return query.map((row) {
|
||||||
|
|
@ -290,7 +290,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||||
.readTable(_db.remoteAlbumEntity)
|
.readTable(_db.remoteAlbumEntity)
|
||||||
.toDto(
|
.toDto(
|
||||||
ownerName: row.read(_db.userEntity.name)!,
|
ownerName: row.read(_db.userEntity.name)!,
|
||||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
|
||||||
);
|
);
|
||||||
return album;
|
return album;
|
||||||
}).watchSingleOrNull();
|
}).watchSingleOrNull();
|
||||||
|
|
|
||||||
|
|
@ -55,24 +55,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||||
return _assetSelectable(id).getSingleOrNull();
|
return _assetSelectable(id).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<RemoteAsset?> watchAsset(String id) {
|
|
||||||
final query =
|
|
||||||
_db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([
|
|
||||||
leftOuterJoin(
|
|
||||||
_db.localAssetEntity,
|
|
||||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
|
||||||
useColumns: false,
|
|
||||||
),
|
|
||||||
])
|
|
||||||
..where(_db.remoteAssetEntity.id.equals(id))
|
|
||||||
..limit(1);
|
|
||||||
|
|
||||||
return query.map((row) {
|
|
||||||
final asset = row.readTable(_db.remoteAssetEntity).toDto();
|
|
||||||
return asset.copyWith(localId: row.read(_db.localAssetEntity.id));
|
|
||||||
}).watchSingleOrNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
|
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
|
||||||
if (asset.stackId == null) {
|
if (asset.stackId == null) {
|
||||||
return Future.value([]);
|
return Future.value([]);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
class IsarUserRepository extends IsarDatabaseRepository {
|
class IsarUserRepository extends IsarDatabaseRepository {
|
||||||
|
|
@ -70,8 +72,16 @@ class DriftUserRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftUserRepository(super.db) : _db = db;
|
const DriftUserRepository(super.db) : _db = db;
|
||||||
|
|
||||||
Future<UserDto?> get(String id) =>
|
Future<UserDto?> get(String id) async {
|
||||||
_db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull().then((user) => user?.toDto());
|
final user = await _db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull();
|
||||||
|
|
||||||
|
if (user == null) return null;
|
||||||
|
|
||||||
|
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(id));
|
||||||
|
final metadata = await query.map((row) => row.toDto()).get();
|
||||||
|
|
||||||
|
return user.toDto(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
Future<UserDto> upsert(UserDto user) async {
|
Future<UserDto> upsert(UserDto user) async {
|
||||||
await _db.userEntity.insertOnConflictUpdate(
|
await _db.userEntity.insertOnConflictUpdate(
|
||||||
|
|
@ -87,10 +97,35 @@ class DriftUserRepository extends DriftDatabaseRepository {
|
||||||
);
|
);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<UserDto>> getAll() async {
|
||||||
|
final users = await _db.userEntity.select().get();
|
||||||
|
final List<UserDto> result = [];
|
||||||
|
|
||||||
|
for (final user in users) {
|
||||||
|
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(user.id));
|
||||||
|
final metadata = await query.map((row) => row.toDto()).get();
|
||||||
|
result.add(user.toDto(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on UserEntityData {
|
extension on UserEntityData {
|
||||||
UserDto toDto() {
|
UserDto toDto([List<UserMetadata>? metadata]) {
|
||||||
|
AvatarColor avatarColor = AvatarColor.primary;
|
||||||
|
bool memoryEnabled = true;
|
||||||
|
|
||||||
|
if (metadata != null) {
|
||||||
|
for (final meta in metadata) {
|
||||||
|
if (meta.key == UserMetadataKey.preferences && meta.preferences != null) {
|
||||||
|
avatarColor = meta.preferences?.userAvatarColor ?? AvatarColor.primary;
|
||||||
|
memoryEnabled = meta.preferences?.memoriesEnabled ?? true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return UserDto(
|
return UserDto(
|
||||||
id: id,
|
id: id,
|
||||||
email: email,
|
email: email,
|
||||||
|
|
@ -99,6 +134,8 @@ extension on UserEntityData {
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
profileChangedAt: profileChangedAt,
|
profileChangedAt: profileChangedAt,
|
||||||
hasProfileImage: hasProfileImage,
|
hasProfileImage: hasProfileImage,
|
||||||
|
avatarColor: avatarColor,
|
||||||
|
memoryEnabled: memoryEnabled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ class DriftUserMetadataRepository extends DriftDatabaseRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on UserMetadataEntityData {
|
extension UserMetadataDataExtension on UserMetadataEntityData {
|
||||||
UserMetadata toDto() => switch (key) {
|
UserMetadata toDto() => switch (key) {
|
||||||
UserMetadataKey.onboarding => UserMetadata(userId: userId, key: key, onboarding: Onboarding.fromMap(value)),
|
UserMetadataKey.onboarding => UserMetadata(userId: userId, key: key, onboarding: Onboarding.fromMap(value)),
|
||||||
UserMetadataKey.preferences => UserMetadata(userId: userId, key: key, preferences: Preferences.fromMap(value)),
|
UserMetadataKey.preferences => UserMetadata(userId: userId, key: key, preferences: Preferences.fromMap(value)),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ abstract final class UserConverter {
|
||||||
isPartnerSharedWith: false,
|
isPartnerSharedWith: false,
|
||||||
profileChangedAt: adminDto.profileChangedAt,
|
profileChangedAt: adminDto.profileChangedAt,
|
||||||
hasProfileImage: adminDto.profileImagePath.isNotEmpty,
|
hasProfileImage: adminDto.profileImagePath.isNotEmpty,
|
||||||
|
quotaSizeInBytes: adminDto.quotaSizeInBytes ?? 0,
|
||||||
|
quotaUsageInBytes: adminDto.quotaUsageInBytes ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto(
|
static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
@ -42,10 +43,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||||
|
|
||||||
await ref.read(backgroundSyncProvider).syncRemote();
|
await ref.read(backgroundSyncProvider).syncRemote();
|
||||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||||
|
await ref.read(driftBackgroundUploadFgService).enableUploadService();
|
||||||
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
|
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopBackup() async {
|
Future<void> stopBackup() async {
|
||||||
|
await ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||||
await ref.read(driftBackupProvider.notifier).cancel();
|
await ref.read(driftBackupProvider.notifier).cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,25 +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()),
|
||||||
// TODO: the user preferences need to be updated
|
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
||||||
// from the server to get live hiding/showing of memory lane
|
|
||||||
|
|
||||||
return memoryLaneProvider.maybeWhen(
|
|
||||||
data: (memories) {
|
|
||||||
return memories.isEmpty || !memoriesEnabled
|
|
||||||
? const Timeline()
|
|
||||||
: Timeline(
|
|
||||||
topSliverWidget: SliverToBoxAdapter(
|
|
||||||
key: Key('memory-lane-${memories.first.assets.first.id}'),
|
|
||||||
child: DriftMemoryLane(memories: memories),
|
|
||||||
),
|
|
||||||
topSliverWidgetHeight: 200,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
orElse: () => const Timeline(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,10 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
|
||||||
try {
|
try {
|
||||||
if (asset.hasLocal && asset.livePhotoVideoId == null) {
|
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||||
final id = asset is LocalAsset ? (asset as LocalAsset).id : (asset as RemoteAsset).localId!;
|
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||||
final file = await const StorageRepository().getFileForAsset(id);
|
final file = await const StorageRepository().getFileForAsset(id);
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
throw Exception('No file found for the video');
|
throw Exception('No file found for the video');
|
||||||
|
|
@ -99,14 +100,14 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
final remoteId = (asset as RemoteAsset).id;
|
final remoteId = (videoAsset as RemoteAsset).id;
|
||||||
|
|
||||||
// Use a network URL for the video player controller
|
// Use a network URL for the video player controller
|
||||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||||
final String videoUrl = asset.livePhotoVideoId != null
|
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||||
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/$postfixUrl'
|
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||||
|
|
||||||
final source = await VideoSource.init(
|
final source = await VideoSource.init(
|
||||||
|
|
@ -116,7 +117,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||||
);
|
);
|
||||||
return source;
|
return source;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.severe('Error creating video source for asset ${asset.name}: $error');
|
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -94,7 +95,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
||||||
imageInfo.dispose();
|
imageInfo.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_fadeController.value = 1.0;
|
||||||
setState(() {
|
setState(() {
|
||||||
_providerImage = imageInfo.image;
|
_providerImage = imageInfo.image;
|
||||||
});
|
});
|
||||||
|
|
@ -115,7 +116,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
||||||
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
|
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
|
||||||
final imageStreamListener = _imageStreamListener = ImageStreamListener(
|
final imageStreamListener = _imageStreamListener = ImageStreamListener(
|
||||||
(ImageInfo imageInfo, bool synchronousCall) {
|
(ImageInfo imageInfo, bool synchronousCall) {
|
||||||
_stopListeningToStream();
|
_stopListeningToThumbhashStream();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
imageInfo.dispose();
|
imageInfo.dispose();
|
||||||
return;
|
return;
|
||||||
|
|
@ -125,7 +126,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (synchronousCall && _providerImage == null) {
|
if ((synchronousCall && _providerImage == null) || !_isVisible()) {
|
||||||
_fadeController.value = 1.0;
|
_fadeController.value = 1.0;
|
||||||
} else if (_fadeController.isAnimating) {
|
} else if (_fadeController.isAnimating) {
|
||||||
_fadeController.forward();
|
_fadeController.forward();
|
||||||
|
|
@ -201,6 +202,15 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
||||||
_loadFromThumbhashProvider();
|
_loadFromThumbhashProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isVisible() {
|
||||||
|
final renderObject = context.findRenderObject() as RenderBox?;
|
||||||
|
if (renderObject == null || !renderObject.attached) return false;
|
||||||
|
|
||||||
|
final topLeft = renderObject.localToGlobal(Offset.zero);
|
||||||
|
final bottomRight = renderObject.localToGlobal(Offset(renderObject.size.width, renderObject.size.height));
|
||||||
|
return topLeft.dy < context.height && bottomRight.dy > 0;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = context.colorScheme;
|
final colorScheme = context.colorScheme;
|
||||||
|
|
@ -226,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;
|
||||||
|
|
||||||
|
|
@ -57,7 +57,10 @@ class TimelineHeader extends StatelessWidget {
|
||||||
if (isMonthHeader)
|
if (isMonthHeader)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(_formatMonth(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 24)),
|
Text(
|
||||||
|
toBeginningOfSentenceCase(_formatMonth(context, date)),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(fontSize: 24),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
||||||
],
|
],
|
||||||
|
|
@ -65,7 +68,10 @@ class TimelineHeader extends StatelessWidget {
|
||||||
if (isDayHeader)
|
if (isDayHeader)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(_formatDay(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 15)),
|
Text(
|
||||||
|
toBeginningOfSentenceCase(_formatDay(context, date)),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
||||||
],
|
],
|
||||||
|
|
@ -92,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),
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,21 @@ abstract class RemoteCacheManager extends CacheManager {
|
||||||
final file = await store.fileSystem.createFile(path);
|
final file = await store.fileSystem.createFile(path);
|
||||||
final sink = file.openWrite();
|
final sink = file.openWrite();
|
||||||
try {
|
try {
|
||||||
await source.pipe(sink);
|
await source.listen(sink.add, cancelOnError: true).asFuture();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await sink.close();
|
||||||
|
await file.delete();
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Failed to delete incomplete cache file: $e');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sink.flush();
|
||||||
await sink.close();
|
await sink.close();
|
||||||
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
await file.delete();
|
await file.delete();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,8 @@ enum AppSettingsEnum<T> {
|
||||||
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.useWifiForUploadVideos, null, false),
|
||||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false);
|
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
||||||
|
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||||
|
|
||||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,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 +113,7 @@ class UploadService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.isNotEmpty) {
|
if (tasks.isNotEmpty) {
|
||||||
enqueueTasks(tasks);
|
await enqueueTasks(tasks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,13 +149,37 @@ class UploadService {
|
||||||
|
|
||||||
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
|
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
|
||||||
count += tasks.length;
|
count += tasks.length;
|
||||||
enqueueTasks(tasks);
|
await enqueueTasks(tasks);
|
||||||
|
|
||||||
onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length));
|
onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enqueue All does not work from the background on Android yet. This method is a temporary workaround
|
||||||
|
// that enqueues tasks one by one.
|
||||||
|
Future<void> startBackupSerial(String userId) async {
|
||||||
|
await _storageRepository.clearCache();
|
||||||
|
|
||||||
|
shouldAbortQueuingTasks = false;
|
||||||
|
|
||||||
|
final candidates = await _backupRepository.getCandidates(userId);
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final asset in candidates) {
|
||||||
|
if (shouldAbortQueuingTasks) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final task = await _getUploadTask(asset);
|
||||||
|
if (task != null) {
|
||||||
|
await _uploadRepository.enqueueBackground(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancel all ongoing uploads and reset the upload queue
|
/// Cancel all ongoing uploads and reset the upload queue
|
||||||
///
|
///
|
||||||
/// Return the number of left over tasks in the queue
|
/// Return the number of left over tasks in the queue
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
||||||
case 'AssetResponseDto':
|
case 'AssetResponseDto':
|
||||||
if (value is Map) {
|
if (value is Map) {
|
||||||
addDefault(value, 'visibility', 'timeline');
|
addDefault(value, 'visibility', 'timeline');
|
||||||
|
addDefault(value, 'createdAt', DateTime.now().toIso8601String());
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'UserAdminResponseDto':
|
case 'UserAdminResponseDto':
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class UserCircleAvatar extends ConsumerWidget {
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(50)),
|
borderRadius: const BorderRadius.all(Radius.circular(50)),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
cacheKey: user.profileChangedAt.toIso8601String(),
|
cacheKey: '${user.id}-${user.profileChangedAt.toIso8601String()}',
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
placeholder: (_, __) => Image.memory(kTransparentImage),
|
placeholder: (_, __) => Image.memory(kTransparentImage),
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/migration.dart';
|
|
||||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||||
|
|
@ -277,7 +276,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
if (isBeta) {
|
if (isBeta) {
|
||||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||||
await runNewSync(ref);
|
|
||||||
context.replaceRoute(const TabShellRoute());
|
context.replaceRoute(const TabShellRoute());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.2
|
- 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,10 @@ part of openapi.api;
|
||||||
class AlbumsAddAssetsResponseDto {
|
class AlbumsAddAssetsResponseDto {
|
||||||
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
|
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
|
||||||
AlbumsAddAssetsResponseDto({
|
AlbumsAddAssetsResponseDto({
|
||||||
required this.albumSuccessCount,
|
|
||||||
required this.assetSuccessCount,
|
|
||||||
this.error,
|
this.error,
|
||||||
required this.success,
|
required this.success,
|
||||||
});
|
});
|
||||||
|
|
||||||
int albumSuccessCount;
|
|
||||||
|
|
||||||
int assetSuccessCount;
|
|
||||||
|
|
||||||
///
|
///
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
/// does not include a default value (using the "default:" property), however, the generated
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
|
@ -35,26 +29,20 @@ class AlbumsAddAssetsResponseDto {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
|
||||||
other.albumSuccessCount == albumSuccessCount &&
|
|
||||||
other.assetSuccessCount == assetSuccessCount &&
|
|
||||||
other.error == error &&
|
other.error == error &&
|
||||||
other.success == success;
|
other.success == success;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(albumSuccessCount.hashCode) +
|
|
||||||
(assetSuccessCount.hashCode) +
|
|
||||||
(error == null ? 0 : error!.hashCode) +
|
(error == null ? 0 : error!.hashCode) +
|
||||||
(success.hashCode);
|
(success.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]';
|
String toString() => 'AlbumsAddAssetsResponseDto[error=$error, success=$success]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'albumSuccessCount'] = this.albumSuccessCount;
|
|
||||||
json[r'assetSuccessCount'] = this.assetSuccessCount;
|
|
||||||
if (this.error != null) {
|
if (this.error != null) {
|
||||||
json[r'error'] = this.error;
|
json[r'error'] = this.error;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -73,8 +61,6 @@ class AlbumsAddAssetsResponseDto {
|
||||||
final json = value.cast<String, dynamic>();
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
return AlbumsAddAssetsResponseDto(
|
return AlbumsAddAssetsResponseDto(
|
||||||
albumSuccessCount: mapValueOfType<int>(json, r'albumSuccessCount')!,
|
|
||||||
assetSuccessCount: mapValueOfType<int>(json, r'assetSuccessCount')!,
|
|
||||||
error: BulkIdErrorReason.fromJson(json[r'error']),
|
error: BulkIdErrorReason.fromJson(json[r'error']),
|
||||||
success: mapValueOfType<bool>(json, r'success')!,
|
success: mapValueOfType<bool>(json, r'success')!,
|
||||||
);
|
);
|
||||||
|
|
@ -124,8 +110,6 @@ class AlbumsAddAssetsResponseDto {
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'albumSuccessCount',
|
|
||||||
'assetSuccessCount',
|
|
||||||
'success',
|
'success',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
11
mobile/openapi/lib/model/asset_response_dto.dart
generated
11
mobile/openapi/lib/model/asset_response_dto.dart
generated
|
|
@ -14,6 +14,7 @@ class AssetResponseDto {
|
||||||
/// Returns a new [AssetResponseDto] instance.
|
/// Returns a new [AssetResponseDto] instance.
|
||||||
AssetResponseDto({
|
AssetResponseDto({
|
||||||
required this.checksum,
|
required this.checksum,
|
||||||
|
required this.createdAt,
|
||||||
required this.deviceAssetId,
|
required this.deviceAssetId,
|
||||||
required this.deviceId,
|
required this.deviceId,
|
||||||
this.duplicateId,
|
this.duplicateId,
|
||||||
|
|
@ -49,6 +50,9 @@ class AssetResponseDto {
|
||||||
/// base64 encoded sha1 hash
|
/// base64 encoded sha1 hash
|
||||||
String checksum;
|
String checksum;
|
||||||
|
|
||||||
|
/// The UTC timestamp when the asset was originally uploaded to Immich.
|
||||||
|
DateTime createdAt;
|
||||||
|
|
||||||
String deviceAssetId;
|
String deviceAssetId;
|
||||||
|
|
||||||
String deviceId;
|
String deviceId;
|
||||||
|
|
@ -142,6 +146,7 @@ class AssetResponseDto {
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||||
other.checksum == checksum &&
|
other.checksum == checksum &&
|
||||||
|
other.createdAt == createdAt &&
|
||||||
other.deviceAssetId == deviceAssetId &&
|
other.deviceAssetId == deviceAssetId &&
|
||||||
other.deviceId == deviceId &&
|
other.deviceId == deviceId &&
|
||||||
other.duplicateId == duplicateId &&
|
other.duplicateId == duplicateId &&
|
||||||
|
|
@ -177,6 +182,7 @@ class AssetResponseDto {
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(checksum.hashCode) +
|
(checksum.hashCode) +
|
||||||
|
(createdAt.hashCode) +
|
||||||
(deviceAssetId.hashCode) +
|
(deviceAssetId.hashCode) +
|
||||||
(deviceId.hashCode) +
|
(deviceId.hashCode) +
|
||||||
(duplicateId == null ? 0 : duplicateId!.hashCode) +
|
(duplicateId == null ? 0 : duplicateId!.hashCode) +
|
||||||
|
|
@ -209,11 +215,12 @@ class AssetResponseDto {
|
||||||
(visibility.hashCode);
|
(visibility.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
|
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'checksum'] = this.checksum;
|
json[r'checksum'] = this.checksum;
|
||||||
|
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
|
||||||
json[r'deviceAssetId'] = this.deviceAssetId;
|
json[r'deviceAssetId'] = this.deviceAssetId;
|
||||||
json[r'deviceId'] = this.deviceId;
|
json[r'deviceId'] = this.deviceId;
|
||||||
if (this.duplicateId != null) {
|
if (this.duplicateId != null) {
|
||||||
|
|
@ -293,6 +300,7 @@ class AssetResponseDto {
|
||||||
|
|
||||||
return AssetResponseDto(
|
return AssetResponseDto(
|
||||||
checksum: mapValueOfType<String>(json, r'checksum')!,
|
checksum: mapValueOfType<String>(json, r'checksum')!,
|
||||||
|
createdAt: mapDateTime(json, r'createdAt', r'')!,
|
||||||
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
|
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
|
||||||
deviceId: mapValueOfType<String>(json, r'deviceId')!,
|
deviceId: mapValueOfType<String>(json, r'deviceId')!,
|
||||||
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
||||||
|
|
@ -371,6 +379,7 @@ class AssetResponseDto {
|
||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'checksum',
|
'checksum',
|
||||||
|
'createdAt',
|
||||||
'deviceAssetId',
|
'deviceAssetId',
|
||||||
'deviceId',
|
'deviceId',
|
||||||
'duration',
|
'duration',
|
||||||
|
|
|
||||||
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue