mirror of
https://github.com/immich-app/immich.git
synced 2025-12-12 17:23:11 +03:00
Compare commits
2 Commits
3af09ad140
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92bc22620b | ||
|
|
41f013387f |
2
.github/.nvmrc
vendored
2
.github/.nvmrc
vendored
@@ -1 +1 @@
|
||||
24.11.1
|
||||
24.11.0
|
||||
|
||||
2
.github/package.json
vendored
2
.github/package.json
vendored
@@ -4,6 +4,6 @@
|
||||
"format:fix": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.7.4"
|
||||
"prettier": "^3.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
2
.github/workflows/cli.yml
vendored
2
.github/workflows/cli.yml
vendored
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "mich"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
name: Build and Push Server
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
2
.github/workflows/merge-translations.yml
vendored
2
.github/workflows/merge-translations.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
6
.github/workflows/prepare-release.yml
vendored
6
.github/workflows/prepare-release.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
6
.github/workflows/release-pr.yml
vendored
6
.github/workflows/release-pr.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|
||||
- name: Create PR
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -571,8 +571,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||
# with:
|
||||
# python-version: 3.11
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -52,7 +52,7 @@
|
||||
},
|
||||
"cSpell.words": ["immich"],
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||
"eslint.validate": ["javascript", "svelte"],
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.1
|
||||
24.11.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
|
||||
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY package* pnpm* .pnpmfile.cjs ./
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^22.19.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
@@ -28,10 +28,10 @@
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
@@ -69,6 +69,6 @@
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.1"
|
||||
"node": "24.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ describe('crawl', () => {
|
||||
.map(([file]) => file);
|
||||
|
||||
// Compare file's content instead of path since a file can be represent in multiple ways.
|
||||
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
|
||||
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
|
||||
ignore: [`**/${exclusionPattern}`],
|
||||
});
|
||||
globbedFiles.push(...crawledFiles);
|
||||
return globbedFiles.toSorted();
|
||||
return globbedFiles.sort();
|
||||
};
|
||||
|
||||
export const sha1 = (filepath: string) => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2023",
|
||||
"target": "es2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.93.10"
|
||||
opentofu = "1.10.7"
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -135,7 +135,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
@@ -95,7 +95,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
|
||||
image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.1
|
||||
24.11.0
|
||||
|
||||
@@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
|
||||
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
|
||||
the job may not have run automatically the first time.
|
||||
|
||||
### How can I hide a photo or video from the timeline?
|
||||
### How can I hide photos from the timeline?
|
||||
|
||||
You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
|
||||
You can _archive_ them.
|
||||
|
||||
### How can I backup data from Immich?
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev
|
||||
|
||||
#### Trigger Dump
|
||||
|
||||
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues).
|
||||
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
|
||||
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
|
||||
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
|
||||
This dumps will count towards the last `X` dumps that will be kept based on your settings.
|
||||
|
||||
@@ -21,9 +21,6 @@ server {
|
||||
# allow large file uploads
|
||||
client_max_body_size 50000M;
|
||||
|
||||
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Set headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following:
|
||||
- [ ] `pnpm run check:typescript` (check typescript)
|
||||
- [ ] `pnpm test` (unit tests)
|
||||
|
||||
:::tip AIO
|
||||
Run all web checks with `pnpm run check:all`
|
||||
:::
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] `pnpm run format` (formatting via Prettier)
|
||||
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
|
||||
|
||||
:::tip AIO
|
||||
Run all web checks with `pnpm run check:all`
|
||||
:::
|
||||
|
||||
## Server Checks
|
||||
|
||||
- [ ] `pnpm run lint` (linting via ESLint)
|
||||
|
||||
@@ -48,6 +48,7 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
|
||||
**Notes:**
|
||||
|
||||
- The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors
|
||||
- In case of rootless docker setup, you need to use root within the container, otherwise you will encounter read/write permission related errors, see comments in `docker/docker-compose.dev.yml`.
|
||||
|
||||
#### Connect web to a remote backend
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ make e2e
|
||||
Before you can run the tests, you need to run the following commands _once_:
|
||||
|
||||
- `pnpm install` (in `e2e/`)
|
||||
- `pnpm run build` (in `cli/`)
|
||||
- `make open-api` (in the project root `/`)
|
||||
|
||||
Once the test environment is running, the e2e tests can be run via:
|
||||
|
||||
@@ -1222,4 +1222,4 @@ Feel free to make a feature request if there's a model you want to use that we d
|
||||
[huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07
|
||||
[huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7
|
||||
[smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search
|
||||
[job-status-page]: https://my.immich.app/admin/queues
|
||||
[job-status-page]: https://my.immich.app/admin/jobs-status
|
||||
|
||||
@@ -53,7 +53,7 @@ Version mismatches between both hosts may cause bugs and instability, so remembe
|
||||
|
||||
Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used.
|
||||
|
||||
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/queues) page for the jobs to be retried.
|
||||
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/jobs-status) page for the jobs to be retried.
|
||||
|
||||
## Load balancing
|
||||
|
||||
|
||||
@@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
|
||||
## Ports
|
||||
|
||||
| Variable | Description | Default | Containers |
|
||||
| :------------ | :------------- | :----------------------------------------: | :----------------------- |
|
||||
| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
|
||||
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
|
||||
| Variable | Description | Default |
|
||||
| :------------ | :------------- | :----------------------------------------: |
|
||||
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
|
||||
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) |
|
||||
|
||||
## Database
|
||||
|
||||
@@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
| `DB_SSL_MODE` | Database SSL mode | | server |
|
||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
||||
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
|
||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
|
||||
|
||||
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
|
||||
|
||||
@@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
|
||||
|
||||
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
|
||||
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database.
|
||||
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`.
|
||||
|
||||
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@docusaurus/module-type-aliases": "~3.9.0",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.7.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.2.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -57,6 +57,6 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.1"
|
||||
"node": "24.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.1
|
||||
24.11.0
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/oidc-provider": "^9.0.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
@@ -35,17 +35,17 @@
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"exiftool-vendored": "^33.0.0",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"exiftool-vendored": "^31.1.0",
|
||||
"globals": "^16.0.0",
|
||||
"jose": "^5.6.3",
|
||||
"luxon": "^3.4.4",
|
||||
"oidc-provider": "^9.0.0",
|
||||
"pg": "^8.11.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sharp": "^0.34.4",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
@@ -54,6 +54,6 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.1"
|
||||
"node": "24.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
|
||||
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
|
||||
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||
|
||||
@@ -61,7 +61,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
|
||||
}
|
||||
}
|
||||
|
||||
return [...selectedDays].toSorted((a, b) => b - a);
|
||||
return [...selectedDays].sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,60 +62,50 @@ export const setupTimelineMockApiRoutes = async (
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const assetId = basename(pathname);
|
||||
const asset = getAsset(timelineRestData, assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: asset,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/ocr', async (route) => {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
await context.route('**/api/assets/**', async (route, request) => {
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||
if (!match) {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const assetId = basename(pathname);
|
||||
const asset = getAsset(timelineRestData, assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: asset,
|
||||
});
|
||||
}
|
||||
|
||||
if (match.groups.size === 'preview') {
|
||||
if (match.groups?.size === 'preview') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
||||
body: await randomPreview(
|
||||
match.groups.assetId,
|
||||
match.groups?.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (match.groups.size === 'thumbnail') {
|
||||
if (match.groups?.size === 'thumbnail') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail(
|
||||
match.groups.assetId,
|
||||
match.groups?.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/albums/**', async (route, request) => {
|
||||
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
||||
const match = request.url().match(pattern);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
PersonCreateDto,
|
||||
QueueCommandDto,
|
||||
QueueName,
|
||||
QueuesResponseLegacyDto,
|
||||
QueuesResponseDto,
|
||||
SharedLinkCreateDto,
|
||||
UpdateLibraryDto,
|
||||
UserAdminCreateDto,
|
||||
@@ -564,13 +564,13 @@ export const utils = {
|
||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => {
|
||||
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
|
||||
const jobCounts = queues[queue].jobCounts;
|
||||
return !jobCounts.active && !jobCounts.waiting;
|
||||
},
|
||||
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
||||
|
||||
@@ -611,53 +611,6 @@ test.describe('Timeline', () => {
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
|
||||
const assetToFavorite = assets[0];
|
||||
changes.assetArchivals.push(assetToFavorite.id);
|
||||
await pageUtils.openArchivePage(page);
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
|
||||
});
|
||||
test('open album, archive photo, open album, unarchive', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
@@ -680,7 +633,8 @@ test.describe('Timeline', () => {
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon');
|
||||
// await thumbnail.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
@@ -702,7 +656,8 @@ test.describe('Timeline', () => {
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
|
||||
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
@@ -757,50 +712,6 @@ test.describe('Timeline', () => {
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
});
|
||||
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
|
||||
await pageUtils.openFavorites(page);
|
||||
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
});
|
||||
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
|
||||
@@ -105,16 +105,20 @@ export const thumbnailUtils = {
|
||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
|
||||
},
|
||||
async expectThumbnailIsFavorite(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
|
||||
await expect(
|
||||
thumbnailUtils
|
||||
.withAssetId(page, assetId)
|
||||
.locator(
|
||||
'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
await expect(
|
||||
thumbnailUtils
|
||||
.withAssetId(page, assetId)
|
||||
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'),
|
||||
).toHaveCount(1);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
@@ -204,18 +208,10 @@ export const pageUtils = {
|
||||
await page.goto(`/photos`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openFavorites(page: Page) {
|
||||
await page.goto(`/favorites`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openAlbumPage(page: Page, albumId: string) {
|
||||
await page.goto(`/albums/${albumId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openArchivePage(page: Page) {
|
||||
await page.goto(`/archive`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
|
||||
await page.goto(`/albums/${albumId}?at=${assetId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await page.getByText('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
@@ -83,7 +83,7 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await page.getByText('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2023",
|
||||
"target": "es2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
|
||||
90
i18n/en.json
90
i18n/en.json
@@ -5,10 +5,8 @@
|
||||
"acknowledge": "Acknowledge",
|
||||
"action": "Action",
|
||||
"action_common_update": "Update",
|
||||
"action_description": "A set of action to perform on the filtered assets",
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"active_count": "Active: {count}",
|
||||
"activity": "Activity",
|
||||
"activity_changed": "Activity is {enabled, select, true {enabled} other {disabled}}",
|
||||
"add": "Add",
|
||||
@@ -16,13 +14,9 @@
|
||||
"add_a_location": "Add a location",
|
||||
"add_a_name": "Add a name",
|
||||
"add_a_title": "Add a title",
|
||||
"add_action": "Add action",
|
||||
"add_action_description": "Click to add an action to perform",
|
||||
"add_birthday": "Add a birthday",
|
||||
"add_endpoint": "Add endpoint",
|
||||
"add_exclusion_pattern": "Add exclusion pattern",
|
||||
"add_filter": "Add filter",
|
||||
"add_filter_description": "Click to add a filter condition",
|
||||
"add_location": "Add location",
|
||||
"add_more_users": "Add more users",
|
||||
"add_partner": "Add partner",
|
||||
@@ -41,7 +35,6 @@
|
||||
"add_to_shared_album": "Add to shared album",
|
||||
"add_upload_to_stack": "Add upload to stack",
|
||||
"add_url": "Add URL",
|
||||
"add_workflow_step": "Add workflow step",
|
||||
"added_to_archive": "Added to archive",
|
||||
"added_to_favorites": "Added to favorites",
|
||||
"added_to_favorites_count": "Added {count, number} to favorites",
|
||||
@@ -74,7 +67,6 @@
|
||||
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
|
||||
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
|
||||
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
|
||||
"copy_config_to_clipboard_description": "Copy the current system config as a JSON object to the clipboard",
|
||||
"create_job": "Create job",
|
||||
"cron_expression": "Cron expression",
|
||||
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
|
||||
@@ -82,8 +74,7 @@
|
||||
"disable_login": "Disable login",
|
||||
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
|
||||
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
|
||||
"export_config_as_json_description": "Download the current system config as a JSON file",
|
||||
"external_libraries_page_description": "Admin external library page",
|
||||
"external_library_management": "External Library Management",
|
||||
"face_detection": "Face detection",
|
||||
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
|
||||
"facial_recognition_job_description": "Group detected faces into people. This step runs after Face Detection is complete. \"Reset\" (re-)clusters all faces. \"Missing\" queues faces that don't have a person assigned.",
|
||||
@@ -111,15 +102,14 @@
|
||||
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
|
||||
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
|
||||
"image_thumbnail_title": "Thumbnail Settings",
|
||||
"import_config_from_json_description": "Import system config by uploading a JSON config file",
|
||||
"job_concurrency": "{job} concurrency",
|
||||
"job_created": "Job created",
|
||||
"job_not_concurrency_safe": "This job is not concurrency-safe.",
|
||||
"job_settings": "Job Settings",
|
||||
"job_settings_description": "Manage job concurrency",
|
||||
"job_status": "Job Status",
|
||||
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
|
||||
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
||||
"jobs_over_time": "Jobs over time",
|
||||
"library_created": "Created library: {library}",
|
||||
"library_deleted": "Library deleted",
|
||||
"library_details": "Library details",
|
||||
@@ -192,7 +182,6 @@
|
||||
"maintenance_start": "Start maintenance mode",
|
||||
"maintenance_start_error": "Failed to start maintenance mode.",
|
||||
"manage_concurrency": "Manage Concurrency",
|
||||
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
|
||||
"manage_log_settings": "Manage log settings",
|
||||
"map_dark_style": "Dark style",
|
||||
"map_enable_description": "Enable map features",
|
||||
@@ -282,14 +271,10 @@
|
||||
"password_settings_description": "Manage password login settings",
|
||||
"paths_validated_successfully": "All paths validated successfully",
|
||||
"person_cleanup_job": "Person cleanup",
|
||||
"queue_details": "Queue Details",
|
||||
"queues": "Job Queues",
|
||||
"queues_page_description": "Admin job queues page",
|
||||
"quota_size_gib": "Quota Size (GiB)",
|
||||
"refreshing_all_libraries": "Refreshing all libraries",
|
||||
"registration": "Admin Registration",
|
||||
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
|
||||
"remove_failed_jobs": "Remove failed jobs",
|
||||
"require_password_change_on_login": "Require user to change password on first login",
|
||||
"reset_settings_to_default": "Reset settings to default",
|
||||
"reset_settings_to_recent_saved": "Reset settings to the recent saved settings",
|
||||
@@ -302,10 +287,8 @@
|
||||
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
|
||||
"server_settings": "Server Settings",
|
||||
"server_settings_description": "Manage server settings",
|
||||
"server_stats_page_description": "Admin server statistics page",
|
||||
"server_welcome_message": "Welcome message",
|
||||
"server_welcome_message_description": "A message that is displayed on the login page.",
|
||||
"settings_page_description": "Admin settings page",
|
||||
"sidecar_job": "Sidecar metadata",
|
||||
"sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
|
||||
"slideshow_duration_description": "Number of seconds to display each image",
|
||||
@@ -424,8 +407,6 @@
|
||||
"user_restore_scheduled_removal": "Restore user - scheduled removal on {date, date, long}",
|
||||
"user_settings": "User Settings",
|
||||
"user_settings_description": "Manage user settings",
|
||||
"user_successfully_removed": "User {email} has been successfully removed.",
|
||||
"users_page_description": "Admin users page",
|
||||
"version_check_enabled_description": "Enable version check",
|
||||
"version_check_implications": "The version check feature relies on periodic communication with github.com",
|
||||
"version_check_settings": "Version Check",
|
||||
@@ -473,7 +454,6 @@
|
||||
"album_remove_user": "Remove user?",
|
||||
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
|
||||
"album_search_not_found": "No albums found matching your search",
|
||||
"album_selected": "Album selected",
|
||||
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
|
||||
"album_summary": "Album summary",
|
||||
"album_updated": "Album updated",
|
||||
@@ -495,7 +475,6 @@
|
||||
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
|
||||
"albums_feature_description": "Collections of assets that can be shared with other users.",
|
||||
"albums_on_device_count": "Albums on device ({count})",
|
||||
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
|
||||
"all": "All",
|
||||
"all_albums": "All albums",
|
||||
"all_people": "All people",
|
||||
@@ -532,12 +511,10 @@
|
||||
"archived_count": "{count, plural, other {Archived #}}",
|
||||
"are_these_the_same_person": "Are these the same person?",
|
||||
"are_you_sure_to_do_this": "Are you sure you want to do this?",
|
||||
"array_field_not_fully_supported": "Array fields require manual JSON editing",
|
||||
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
|
||||
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
|
||||
"asset_added_to_album": "Added to album",
|
||||
"asset_adding_to_album": "Adding to album…",
|
||||
"asset_created": "Asset created",
|
||||
"asset_description_updated": "Asset description has been updated",
|
||||
"asset_filename_is_offline": "Asset {filename} is offline",
|
||||
"asset_has_unassigned_faces": "Asset has unassigned faces",
|
||||
@@ -720,8 +697,6 @@
|
||||
"change_password_form_password_mismatch": "Passwords do not match",
|
||||
"change_password_form_reenter_new_password": "Re-enter New Password",
|
||||
"change_pin_code": "Change PIN code",
|
||||
"change_trigger": "Change trigger",
|
||||
"change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
|
||||
"change_your_password": "Change your password",
|
||||
"changed_visibility_successfully": "Changed visibility successfully",
|
||||
"charging": "Charging",
|
||||
@@ -752,7 +727,6 @@
|
||||
"collapse_all": "Collapse all",
|
||||
"color": "Color",
|
||||
"color_theme": "Color theme",
|
||||
"command": "Command",
|
||||
"comment_deleted": "Comment deleted",
|
||||
"comment_options": "Comment options",
|
||||
"comments_and_likes": "Comments & likes",
|
||||
@@ -797,7 +771,6 @@
|
||||
"create_album": "Create album",
|
||||
"create_album_page_untitled": "Untitled",
|
||||
"create_api_key": "Create API key",
|
||||
"create_first_workflow": "Create first workflow",
|
||||
"create_library": "Create Library",
|
||||
"create_link": "Create link",
|
||||
"create_link_to_share": "Create link to share",
|
||||
@@ -812,7 +785,6 @@
|
||||
"create_tag": "Create tag",
|
||||
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
|
||||
"create_user": "Create user",
|
||||
"create_workflow": "Create workflow",
|
||||
"created": "Created",
|
||||
"created_at": "Created",
|
||||
"creating_linked_albums": "Creating linked albums...",
|
||||
@@ -879,7 +851,6 @@
|
||||
"deselect_all": "Deselect All",
|
||||
"details": "Details",
|
||||
"direction": "Direction",
|
||||
"disable": "Disable",
|
||||
"disabled": "Disabled",
|
||||
"disallow_edits": "Disallow edits",
|
||||
"discord": "Discord",
|
||||
@@ -942,13 +913,11 @@
|
||||
"edit_tag": "Edit tag",
|
||||
"edit_title": "Edit Title",
|
||||
"edit_user": "Edit user",
|
||||
"edit_workflow": "Edit workflow",
|
||||
"editor": "Editor",
|
||||
"editor_close_without_save_prompt": "The changes will not be saved",
|
||||
"editor_close_without_save_title": "Close editor?",
|
||||
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
|
||||
"editor_crop_tool_h2_rotation": "Rotation",
|
||||
"editor_mode": "Editor mode",
|
||||
"email": "Email",
|
||||
"email_notifications": "Email notifications",
|
||||
"empty_folder": "This folder is empty",
|
||||
@@ -1029,7 +998,6 @@
|
||||
"unable_to_complete_oauth_login": "Unable to complete OAuth login",
|
||||
"unable_to_connect": "Unable to connect",
|
||||
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
|
||||
"unable_to_create": "Unable to create workflow",
|
||||
"unable_to_create_admin_account": "Unable to create admin account",
|
||||
"unable_to_create_api_key": "Unable to create a new API Key",
|
||||
"unable_to_create_library": "Unable to create library",
|
||||
@@ -1040,7 +1008,6 @@
|
||||
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
|
||||
"unable_to_delete_shared_link": "Unable to delete shared link",
|
||||
"unable_to_delete_user": "Unable to delete user",
|
||||
"unable_to_delete_workflow": "Unable to delete workflow",
|
||||
"unable_to_download_files": "Unable to download files",
|
||||
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
|
||||
"unable_to_empty_trash": "Unable to empty trash",
|
||||
@@ -1091,7 +1058,6 @@
|
||||
"unable_to_update_settings": "Unable to update settings",
|
||||
"unable_to_update_timeline_display_status": "Unable to update timeline display status",
|
||||
"unable_to_update_user": "Unable to update user",
|
||||
"unable_to_update_workflow": "Unable to update workflow",
|
||||
"unable_to_upload_file": "Unable to upload file"
|
||||
},
|
||||
"exclusion_pattern": "Exclusion pattern",
|
||||
@@ -1125,7 +1091,6 @@
|
||||
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
|
||||
"face_unassigned": "Unassigned",
|
||||
"failed": "Failed",
|
||||
"failed_count": "Failed: {count}",
|
||||
"failed_to_authenticate": "Failed to authenticate",
|
||||
"failed_to_load_assets": "Failed to load assets",
|
||||
"failed_to_load_folder": "Failed to load folder",
|
||||
@@ -1144,10 +1109,8 @@
|
||||
"filename": "Filename",
|
||||
"filetype": "Filetype",
|
||||
"filter": "Filter",
|
||||
"filter_description": "Conditions to filter the target assets",
|
||||
"filter_people": "Filter people",
|
||||
"filter_places": "Filter places",
|
||||
"filters": "Filters",
|
||||
"find_them_fast": "Find them fast by name with search",
|
||||
"first": "First",
|
||||
"fix_incorrect_match": "Fix incorrect match",
|
||||
@@ -1163,7 +1126,6 @@
|
||||
"general": "General",
|
||||
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
|
||||
"get_help": "Get Help",
|
||||
"get_people_error": "Error getting people",
|
||||
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
|
||||
"getting_started": "Getting Started",
|
||||
"go_back": "Go back",
|
||||
@@ -1195,7 +1157,6 @@
|
||||
"hide_named_person": "Hide person {name}",
|
||||
"hide_password": "Hide password",
|
||||
"hide_person": "Hide person",
|
||||
"hide_schema": "Hide schema",
|
||||
"hide_text_recognition": "Hide text recognition",
|
||||
"hide_unnamed_people": "Hide unnamed people",
|
||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||
@@ -1268,8 +1229,6 @@
|
||||
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
|
||||
"items_count": "{count, plural, one {# item} other {# items}}",
|
||||
"jobs": "Jobs",
|
||||
"json_editor": "JSON editor",
|
||||
"json_error": "JSON error",
|
||||
"keep": "Keep",
|
||||
"keep_all": "Keep All",
|
||||
"keep_this_delete_others": "Keep this, delete others",
|
||||
@@ -1438,13 +1397,11 @@
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"more": "More",
|
||||
"move": "Move",
|
||||
"move_down": "Move down",
|
||||
"move_off_locked_folder": "Move out of locked folder",
|
||||
"move_to": "Move to",
|
||||
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||
"move_to_locked_folder": "Move to locked folder",
|
||||
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
||||
"move_up": "Move up",
|
||||
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
|
||||
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
|
||||
"moved_to_trash": "Moved to trash",
|
||||
@@ -1454,7 +1411,6 @@
|
||||
"my_albums": "My albums",
|
||||
"name": "Name",
|
||||
"name_or_nickname": "Name or nickname",
|
||||
"name_required": "Name is required",
|
||||
"navigate": "Navigate",
|
||||
"navigate_to_time": "Navigate to Time",
|
||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||
@@ -1479,7 +1435,6 @@
|
||||
"next": "Next",
|
||||
"next_memory": "Next memory",
|
||||
"no": "No",
|
||||
"no_actions_added": "No actions added yet",
|
||||
"no_albums_message": "Create an album to organize your photos and videos",
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
"no_albums_yet": "It looks like you do not have any albums yet.",
|
||||
@@ -1489,13 +1444,11 @@
|
||||
"no_cast_devices_found": "No cast devices found",
|
||||
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
||||
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
|
||||
"no_configuration_needed": "No configuration needed",
|
||||
"no_devices": "No authorized devices",
|
||||
"no_duplicates_found": "No duplicates were found.",
|
||||
"no_exif_info_available": "No exif info available",
|
||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||
"no_filters_added": "No filters added yet",
|
||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||
"no_local_assets_found": "No local assets found with this checksum",
|
||||
"no_location_set": "No location set",
|
||||
@@ -1558,7 +1511,6 @@
|
||||
"other_variables": "Other variables",
|
||||
"owned": "Owned",
|
||||
"owner": "Owner",
|
||||
"page": "Page",
|
||||
"partner": "Partner",
|
||||
"partner_can_access": "{partner} can access",
|
||||
"partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
|
||||
@@ -1591,7 +1543,6 @@
|
||||
"people": "People",
|
||||
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
|
||||
"people_feature_description": "Browsing photos and videos grouped by people",
|
||||
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
|
||||
"people_sidebar_description": "Display a link to People in the sidebar",
|
||||
"permanent_deletion_warning": "Permanent deletion warning",
|
||||
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
|
||||
@@ -1616,8 +1567,6 @@
|
||||
"person_age_years": "{years, plural, other {# years}} old",
|
||||
"person_birthdate": "Born on {date}",
|
||||
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
||||
"person_recognized": "Person recognized",
|
||||
"person_selected": "Person selected",
|
||||
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
|
||||
"photos": "Photos",
|
||||
"photos_and_videos": "Photos & Videos",
|
||||
@@ -1867,22 +1816,17 @@
|
||||
"second": "Second",
|
||||
"see_all_people": "See all people",
|
||||
"select": "Select",
|
||||
"select_album": "Select album",
|
||||
"select_album_cover": "Select album cover",
|
||||
"select_albums": "Select albums",
|
||||
"select_all": "Select all",
|
||||
"select_all_duplicates": "Select all duplicates",
|
||||
"select_all_in": "Select all in {group}",
|
||||
"select_avatar_color": "Select avatar color",
|
||||
"select_count": "{count, plural, one {Select #} other {Select #}}",
|
||||
"select_face": "Select face",
|
||||
"select_featured_photo": "Select featured photo",
|
||||
"select_from_computer": "Select from computer",
|
||||
"select_keep_all": "Select keep all",
|
||||
"select_library_owner": "Select library owner",
|
||||
"select_new_face": "Select new face",
|
||||
"select_people": "Select people",
|
||||
"select_person": "Select person",
|
||||
"select_person_to_tag": "Select a person to tag",
|
||||
"select_photos": "Select photos",
|
||||
"select_trash_all": "Select trash all",
|
||||
@@ -2018,7 +1962,6 @@
|
||||
"show_password": "Show password",
|
||||
"show_person_options": "Show person options",
|
||||
"show_progress_bar": "Show Progress Bar",
|
||||
"show_schema": "Show schema",
|
||||
"show_search_options": "Show search options",
|
||||
"show_shared_links": "Show shared links",
|
||||
"show_slideshow_transition": "Show slideshow transition",
|
||||
@@ -2128,7 +2071,6 @@
|
||||
"to_select": "to select",
|
||||
"to_trash": "Trash",
|
||||
"toggle_settings": "Toggle settings",
|
||||
"toggle_theme_description": "Toggle theme",
|
||||
"total": "Total",
|
||||
"total_usage": "Total usage",
|
||||
"trash": "Trash",
|
||||
@@ -2146,13 +2088,6 @@
|
||||
"trash_page_select_assets_btn": "Select assets",
|
||||
"trash_page_title": "Trash ({count})",
|
||||
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
|
||||
"trigger": "Trigger",
|
||||
"trigger_asset_uploaded": "Asset Uploaded",
|
||||
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
|
||||
"trigger_description": "An event that kick off the workflow",
|
||||
"trigger_person_recognized": "Person Recognized",
|
||||
"trigger_person_recognized_description": "Triggered when a person is detected",
|
||||
"trigger_type": "Trigger type",
|
||||
"troubleshoot": "Troubleshoot",
|
||||
"type": "Type",
|
||||
"unable_to_change_pin_code": "Unable to change PIN code",
|
||||
@@ -2183,9 +2118,7 @@
|
||||
"unstack": "Un-stack",
|
||||
"unstack_action_prompt": "{count} unstacked",
|
||||
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
|
||||
"unsupported_field_type": "Unsupported field type",
|
||||
"untagged": "Untagged",
|
||||
"untitled_workflow": "Untitled workflow",
|
||||
"up_next": "Up next",
|
||||
"update_location_action_prompt": "Update the location of {count} selected assets with:",
|
||||
"updated_at": "Updated",
|
||||
@@ -2231,7 +2164,6 @@
|
||||
"utilities": "Utilities",
|
||||
"validate": "Validate",
|
||||
"validate_endpoint_error": "Please enter a valid URL",
|
||||
"validation_error": "Validation error",
|
||||
"variables": "Variables",
|
||||
"version": "Version",
|
||||
"version_announcement_closing": "Your friend, Alex",
|
||||
@@ -2247,7 +2179,6 @@
|
||||
"view_album": "View Album",
|
||||
"view_all": "View All",
|
||||
"view_all_users": "View all users",
|
||||
"view_asset_owners": "View asset owners",
|
||||
"view_details": "View Details",
|
||||
"view_in_timeline": "View in timeline",
|
||||
"view_link": "View link",
|
||||
@@ -2263,28 +2194,13 @@
|
||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||
"viewer_unstack": "Un-Stack",
|
||||
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
|
||||
"visual": "Visual",
|
||||
"visual_builder": "Visual builder",
|
||||
"waiting": "Waiting",
|
||||
"waiting_count": "Waiting: {count}",
|
||||
"warning": "Warning",
|
||||
"week": "Week",
|
||||
"welcome": "Welcome",
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
|
||||
"workflow_deleted": "Workflow deleted",
|
||||
"workflow_description": "Workflow description",
|
||||
"workflow_info": "Workflow info",
|
||||
"workflow_json": "Workflow JSON",
|
||||
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
|
||||
"workflow_name": "Workflow name",
|
||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
||||
"workflow_summary": "Workflow summary",
|
||||
"workflow_update_success": "Workflow updated successfully",
|
||||
"workflow_updated": "Workflow updated",
|
||||
"workflows": "Workflows",
|
||||
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
|
||||
"workflow": "Workflow",
|
||||
"wrong_pin_code": "Wrong PIN code",
|
||||
"year": "Year",
|
||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:fc1f2e357c307c4044133952b203e66a47e7726821a664f603a180a0c5823844 AS builder-cpu
|
||||
|
||||
FROM builder-cpu AS builder-openvino
|
||||
|
||||
@@ -22,10 +22,10 @@ FROM builder-cpu AS builder-rknn
|
||||
|
||||
# Warning: 25GiB+ disk space required to pull this image
|
||||
# TODO: find a way to reduce the image size
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
|
||||
|
||||
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
||||
ARG ONNXRUNTIME_VERSION="v1.22.1"
|
||||
ARG ONNXRUNTIME_VERSION="v1.20.1"
|
||||
WORKDIR /code
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
|
||||
@@ -68,12 +68,12 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
|
||||
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
||||
fi
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-cpu
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
|
||||
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
@@ -102,7 +102,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
|
||||
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
||||
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
||||
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
|
||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
|
||||
|
||||
FROM prod-cpu AS prod-armnn
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ class TextDetector(InferenceModel):
|
||||
ratio = float(self.max_resolution) / img.height
|
||||
else:
|
||||
ratio = float(self.max_resolution) / img.width
|
||||
ratio = min(ratio, 1.0)
|
||||
|
||||
resize_h = int(img.height * ratio)
|
||||
resize_w = int(img.width * ratio)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
|
||||
index 2714e6f59..a69da76b4 100644
|
||||
index d90a2a355..bb1a7de12 100644
|
||||
--- a/cmake/CMakeLists.txt
|
||||
+++ b/cmake/CMakeLists.txt
|
||||
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
|
||||
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
|
||||
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
|
||||
else()
|
||||
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
endif()
|
||||
@@ -295,7 +295,7 @@ if (onnxruntime_USE_ROCM)
|
||||
endif()
|
||||
|
||||
|
||||
if (NOT CMAKE_HIP_ARCHITECTURES)
|
||||
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
||||
endif()
|
||||
|
||||
file(GLOB rocm_cmake_components ${onnxruntime_ROCM_HOME}/lib/cmake/*)
|
||||
|
||||
3598
machine-learning/uv.lock
generated
3598
machine-learning/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,11 @@
|
||||
experimental_monorepo_root = true
|
||||
|
||||
[tools]
|
||||
node = "24.11.1"
|
||||
node = "24.11.0"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.24.0"
|
||||
terragrunt = "0.93.10"
|
||||
opentofu = "1.10.7"
|
||||
java = "25.0.1"
|
||||
pnpm = "10.20.0"
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
|
||||
@@ -105,6 +105,7 @@ dependencies {
|
||||
def serialization_version = '1.8.1'
|
||||
def compose_version = '1.1.1'
|
||||
def gson_version = '2.10.1'
|
||||
def room_version = "2.8.3"
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
@@ -113,6 +114,8 @@ dependencies {
|
||||
implementation "com.google.guava:guava:$guava_version"
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2"
|
||||
implementation "com.squareup.okhttp3:okhttp:5.3.1"
|
||||
|
||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||
@@ -127,6 +130,10 @@ dependencies {
|
||||
implementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
implementation "androidx.compose.material3:material3:1.2.1"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
|
||||
|
||||
// Room Database
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
ksp "androidx.room:room-compiler:$room_version"
|
||||
}
|
||||
|
||||
// This is uncommented in F-Droid build script
|
||||
|
||||
@@ -7,11 +7,13 @@ import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||
import app.alextran.immich.upload.NetworkMonitor
|
||||
|
||||
class ImmichApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val config = Configuration.Builder().build()
|
||||
NetworkMonitor.initialize(this)
|
||||
WorkManager.initialize(this, config)
|
||||
// always start BackupWorker after WorkManager init; this fixes the following bug:
|
||||
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
|
||||
|
||||
@@ -15,6 +15,8 @@ import app.alextran.immich.images.ThumbnailsImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
import app.alextran.immich.upload.UploadApi
|
||||
import app.alextran.immich.upload.UploadTaskImpl
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
@@ -39,6 +41,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
UploadApi.setUp(messenger, UploadTaskImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.net.URL
|
||||
import java.util.Date
|
||||
|
||||
class Converters {
|
||||
private val gson = Gson()
|
||||
|
||||
@TypeConverter
|
||||
fun fromTimestamp(value: Long?): Date? = value?.let { Date(it * 1000) }
|
||||
|
||||
@TypeConverter
|
||||
fun dateToTimestamp(date: Date?): Long? = date?.let { it.time / 1000 }
|
||||
|
||||
@TypeConverter
|
||||
fun fromUrl(value: String?): URL? = value?.let { URL(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun urlToString(url: URL?): String? = url?.toString()
|
||||
|
||||
@TypeConverter
|
||||
fun fromStoreKey(value: Int?): StoreKey? = value?.let { StoreKey.fromInt(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun storeKeyToInt(storeKey: StoreKey?): Int? = storeKey?.rawValue
|
||||
|
||||
@TypeConverter
|
||||
fun fromTaskStatus(value: Int?): TaskStatus? = value?.let { TaskStatus.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun taskStatusToInt(status: TaskStatus?): Int? = status?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromBackupSelection(value: Int?): BackupSelection? = value?.let { BackupSelection.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun backupSelectionToInt(selection: BackupSelection?): Int? = selection?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromAvatarColor(value: Int?): AvatarColor? = value?.let { AvatarColor.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun avatarColorToInt(color: AvatarColor?): Int? = color?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromAlbumUserRole(value: Int?): AlbumUserRole? = value?.let { AlbumUserRole.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun albumUserRoleToInt(role: AlbumUserRole?): Int? = role?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromMemoryType(value: Int?): MemoryType? = value?.let { MemoryType.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun memoryTypeToInt(type: MemoryType?): Int? = type?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromAssetVisibility(value: Int?): AssetVisibility? = value?.let { AssetVisibility.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun assetVisibilityToInt(visibility: AssetVisibility?): Int? = visibility?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromSourceType(value: String?): SourceType? = value?.let { SourceType.fromString(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun sourceTypeToString(type: SourceType?): String? = type?.value
|
||||
|
||||
@TypeConverter
|
||||
fun fromUploadMethod(value: Int?): UploadMethod? = value?.let { UploadMethod.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun uploadMethodToInt(method: UploadMethod?): Int? = method?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromUploadErrorCode(value: Int?): UploadErrorCode? = value?.let { UploadErrorCode.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun uploadErrorCodeToInt(code: UploadErrorCode?): Int? = code?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromAssetType(value: Int?): AssetType? = value?.let { AssetType.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun assetTypeToInt(type: AssetType?): Int? = type?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringMap(value: String?): Map<String, String>? {
|
||||
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||
return gson.fromJson(value, type)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun stringMapToString(map: Map<String, String>?): String? = gson.toJson(map)
|
||||
|
||||
@TypeConverter
|
||||
fun fromEndpointStatus(value: String?): EndpointStatus? = value?.let { EndpointStatus.fromString(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun endpointStatusToString(status: EndpointStatus?): String? = status?.value
|
||||
|
||||
@TypeConverter
|
||||
fun fromEndpointList(value: String?): List<Endpoint>? {
|
||||
val type = object : TypeToken<List<Endpoint>>() {}.type
|
||||
return gson.fromJson(value, type)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun endpointListToString(list: List<Endpoint>?): String? = gson.toJson(list)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
AssetFace::class,
|
||||
AuthUser::class,
|
||||
LocalAlbum::class,
|
||||
LocalAlbumAsset::class,
|
||||
LocalAsset::class,
|
||||
MemoryAsset::class,
|
||||
Memory::class,
|
||||
Partner::class,
|
||||
Person::class,
|
||||
RemoteAlbum::class,
|
||||
RemoteAlbumAsset::class,
|
||||
RemoteAlbumUser::class,
|
||||
RemoteAsset::class,
|
||||
RemoteExif::class,
|
||||
Stack::class,
|
||||
Store::class,
|
||||
UploadTask::class,
|
||||
UploadTaskStat::class,
|
||||
User::class,
|
||||
UserMetadata::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun localAssetDao(): LocalAssetDao
|
||||
abstract fun storeDao(): StoreDao
|
||||
abstract fun uploadTaskDao(): UploadTaskDao
|
||||
abstract fun uploadTaskStatDao(): UploadTaskStatDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"app_database"
|
||||
).build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import java.net.URL
|
||||
import java.util.Date
|
||||
|
||||
enum class StoreKey(val rawValue: Int) {
|
||||
VERSION(0),
|
||||
DEVICE_ID_HASH(3),
|
||||
BACKUP_TRIGGER_DELAY(8),
|
||||
TILES_PER_ROW(103),
|
||||
GROUP_ASSETS_BY(105),
|
||||
UPLOAD_ERROR_NOTIFICATION_GRACE_PERIOD(106),
|
||||
THUMBNAIL_CACHE_SIZE(110),
|
||||
IMAGE_CACHE_SIZE(111),
|
||||
ALBUM_THUMBNAIL_CACHE_SIZE(112),
|
||||
SELECTED_ALBUM_SORT_ORDER(113),
|
||||
LOG_LEVEL(115),
|
||||
MAP_RELATIVE_DATE(119),
|
||||
MAP_THEME_MODE(124),
|
||||
|
||||
ASSET_ETAG(1),
|
||||
CURRENT_USER(2),
|
||||
DEVICE_ID(4),
|
||||
ACCESS_TOKEN(11),
|
||||
SERVER_ENDPOINT(12),
|
||||
SSL_CLIENT_CERT_DATA(15),
|
||||
SSL_CLIENT_PASSWD(16),
|
||||
THEME_MODE(102),
|
||||
CUSTOM_HEADERS(127),
|
||||
PRIMARY_COLOR(128),
|
||||
PREFERRED_WIFI_NAME(133),
|
||||
|
||||
EXTERNAL_ENDPOINT_LIST(135),
|
||||
|
||||
LOCAL_ENDPOINT(134),
|
||||
SERVER_URL(10),
|
||||
|
||||
BACKUP_FAILED_SINCE(5),
|
||||
|
||||
BACKUP_REQUIRE_WIFI(6),
|
||||
BACKUP_REQUIRE_CHARGING(7),
|
||||
AUTO_BACKUP(13),
|
||||
BACKGROUND_BACKUP(14),
|
||||
LOAD_PREVIEW(100),
|
||||
LOAD_ORIGINAL(101),
|
||||
DYNAMIC_LAYOUT(104),
|
||||
BACKGROUND_BACKUP_TOTAL_PROGRESS(107),
|
||||
BACKGROUND_BACKUP_SINGLE_PROGRESS(108),
|
||||
STORAGE_INDICATOR(109),
|
||||
ADVANCED_TROUBLESHOOTING(114),
|
||||
PREFER_REMOTE_IMAGE(116),
|
||||
LOOP_VIDEO(117),
|
||||
MAP_SHOW_FAVORITE_ONLY(118),
|
||||
SELF_SIGNED_CERT(120),
|
||||
MAP_INCLUDE_ARCHIVED(121),
|
||||
IGNORE_ICLOUD_ASSETS(122),
|
||||
SELECTED_ALBUM_SORT_REVERSE(123),
|
||||
MAP_WITH_PARTNERS(125),
|
||||
ENABLE_HAPTIC_FEEDBACK(126),
|
||||
DYNAMIC_THEME(129),
|
||||
COLORFUL_INTERFACE(130),
|
||||
SYNC_ALBUMS(131),
|
||||
AUTO_ENDPOINT_SWITCHING(132),
|
||||
LOAD_ORIGINAL_VIDEO(136),
|
||||
MANAGE_LOCAL_MEDIA_ANDROID(137),
|
||||
READONLY_MODE_ENABLED(138),
|
||||
AUTO_PLAY_VIDEO(139),
|
||||
PHOTO_MANAGER_CUSTOM_FILTER(1000),
|
||||
BETA_PROMPT_SHOWN(1001),
|
||||
BETA_TIMELINE(1002),
|
||||
ENABLE_BACKUP(1003),
|
||||
USE_WIFI_FOR_UPLOAD_VIDEOS(1004),
|
||||
USE_WIFI_FOR_UPLOAD_PHOTOS(1005),
|
||||
NEED_BETA_MIGRATION(1006),
|
||||
SHOULD_RESET_SYNC(1007);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): StoreKey? = entries.find { it.rawValue == value }
|
||||
|
||||
// Int keys
|
||||
val version = TypedStoreKey<Int>(VERSION)
|
||||
val deviceIdHash = TypedStoreKey<Int>(DEVICE_ID_HASH)
|
||||
val backupTriggerDelay = TypedStoreKey<Int>(BACKUP_TRIGGER_DELAY)
|
||||
val tilesPerRow = TypedStoreKey<Int>(TILES_PER_ROW)
|
||||
val groupAssetsBy = TypedStoreKey<Int>(GROUP_ASSETS_BY)
|
||||
val uploadErrorNotificationGracePeriod = TypedStoreKey<Int>(UPLOAD_ERROR_NOTIFICATION_GRACE_PERIOD)
|
||||
val thumbnailCacheSize = TypedStoreKey<Int>(THUMBNAIL_CACHE_SIZE)
|
||||
val imageCacheSize = TypedStoreKey<Int>(IMAGE_CACHE_SIZE)
|
||||
val albumThumbnailCacheSize = TypedStoreKey<Int>(ALBUM_THUMBNAIL_CACHE_SIZE)
|
||||
val selectedAlbumSortOrder = TypedStoreKey<Int>(SELECTED_ALBUM_SORT_ORDER)
|
||||
val logLevel = TypedStoreKey<Int>(LOG_LEVEL)
|
||||
val mapRelativeDate = TypedStoreKey<Int>(MAP_RELATIVE_DATE)
|
||||
val mapThemeMode = TypedStoreKey<Int>(MAP_THEME_MODE)
|
||||
|
||||
// String keys
|
||||
val assetETag = TypedStoreKey<String>(ASSET_ETAG)
|
||||
val currentUser = TypedStoreKey<String>(CURRENT_USER)
|
||||
val deviceId = TypedStoreKey<String>(DEVICE_ID)
|
||||
val accessToken = TypedStoreKey<String>(ACCESS_TOKEN)
|
||||
val sslClientCertData = TypedStoreKey<String>(SSL_CLIENT_CERT_DATA)
|
||||
val sslClientPasswd = TypedStoreKey<String>(SSL_CLIENT_PASSWD)
|
||||
val themeMode = TypedStoreKey<String>(THEME_MODE)
|
||||
val customHeaders = TypedStoreKey<Map<String, String>>(CUSTOM_HEADERS)
|
||||
val primaryColor = TypedStoreKey<String>(PRIMARY_COLOR)
|
||||
val preferredWifiName = TypedStoreKey<String>(PREFERRED_WIFI_NAME)
|
||||
|
||||
// Endpoint keys
|
||||
val externalEndpointList = TypedStoreKey<List<Endpoint>>(EXTERNAL_ENDPOINT_LIST)
|
||||
|
||||
// URL keys
|
||||
val localEndpoint = TypedStoreKey<URL>(LOCAL_ENDPOINT)
|
||||
val serverEndpoint = TypedStoreKey<URL>(SERVER_ENDPOINT)
|
||||
val serverUrl = TypedStoreKey<URL>(SERVER_URL)
|
||||
|
||||
// Date keys
|
||||
val backupFailedSince = TypedStoreKey<Date>(BACKUP_FAILED_SINCE)
|
||||
|
||||
// Bool keys
|
||||
val backupRequireWifi = TypedStoreKey<Boolean>(BACKUP_REQUIRE_WIFI)
|
||||
val backupRequireCharging = TypedStoreKey<Boolean>(BACKUP_REQUIRE_CHARGING)
|
||||
val autoBackup = TypedStoreKey<Boolean>(AUTO_BACKUP)
|
||||
val backgroundBackup = TypedStoreKey<Boolean>(BACKGROUND_BACKUP)
|
||||
val loadPreview = TypedStoreKey<Boolean>(LOAD_PREVIEW)
|
||||
val loadOriginal = TypedStoreKey<Boolean>(LOAD_ORIGINAL)
|
||||
val dynamicLayout = TypedStoreKey<Boolean>(DYNAMIC_LAYOUT)
|
||||
val backgroundBackupTotalProgress = TypedStoreKey<Boolean>(BACKGROUND_BACKUP_TOTAL_PROGRESS)
|
||||
val backgroundBackupSingleProgress = TypedStoreKey<Boolean>(BACKGROUND_BACKUP_SINGLE_PROGRESS)
|
||||
val storageIndicator = TypedStoreKey<Boolean>(STORAGE_INDICATOR)
|
||||
val advancedTroubleshooting = TypedStoreKey<Boolean>(ADVANCED_TROUBLESHOOTING)
|
||||
val preferRemoteImage = TypedStoreKey<Boolean>(PREFER_REMOTE_IMAGE)
|
||||
val loopVideo = TypedStoreKey<Boolean>(LOOP_VIDEO)
|
||||
val mapShowFavoriteOnly = TypedStoreKey<Boolean>(MAP_SHOW_FAVORITE_ONLY)
|
||||
val selfSignedCert = TypedStoreKey<Boolean>(SELF_SIGNED_CERT)
|
||||
val mapIncludeArchived = TypedStoreKey<Boolean>(MAP_INCLUDE_ARCHIVED)
|
||||
val ignoreIcloudAssets = TypedStoreKey<Boolean>(IGNORE_ICLOUD_ASSETS)
|
||||
val selectedAlbumSortReverse = TypedStoreKey<Boolean>(SELECTED_ALBUM_SORT_REVERSE)
|
||||
val mapwithPartners = TypedStoreKey<Boolean>(MAP_WITH_PARTNERS)
|
||||
val enableHapticFeedback = TypedStoreKey<Boolean>(ENABLE_HAPTIC_FEEDBACK)
|
||||
val dynamicTheme = TypedStoreKey<Boolean>(DYNAMIC_THEME)
|
||||
val colorfulInterface = TypedStoreKey<Boolean>(COLORFUL_INTERFACE)
|
||||
val syncAlbums = TypedStoreKey<Boolean>(SYNC_ALBUMS)
|
||||
val autoEndpointSwitching = TypedStoreKey<Boolean>(AUTO_ENDPOINT_SWITCHING)
|
||||
val loadOriginalVideo = TypedStoreKey<Boolean>(LOAD_ORIGINAL_VIDEO)
|
||||
val manageLocalMediaAndroid = TypedStoreKey<Boolean>(MANAGE_LOCAL_MEDIA_ANDROID)
|
||||
val readonlyModeEnabled = TypedStoreKey<Boolean>(READONLY_MODE_ENABLED)
|
||||
val autoPlayVideo = TypedStoreKey<Boolean>(AUTO_PLAY_VIDEO)
|
||||
val photoManagerCustomFilter = TypedStoreKey<Boolean>(PHOTO_MANAGER_CUSTOM_FILTER)
|
||||
val betaPromptShown = TypedStoreKey<Boolean>(BETA_PROMPT_SHOWN)
|
||||
val betaTimeline = TypedStoreKey<Boolean>(BETA_TIMELINE)
|
||||
val enableBackup = TypedStoreKey<Boolean>(ENABLE_BACKUP)
|
||||
val useWifiForUploadVideos = TypedStoreKey<Boolean>(USE_WIFI_FOR_UPLOAD_VIDEOS)
|
||||
val useWifiForUploadPhotos = TypedStoreKey<Boolean>(USE_WIFI_FOR_UPLOAD_PHOTOS)
|
||||
val needBetaMigration = TypedStoreKey<Boolean>(NEED_BETA_MIGRATION)
|
||||
val shouldResetSync = TypedStoreKey<Boolean>(SHOULD_RESET_SYNC)
|
||||
}
|
||||
}
|
||||
|
||||
enum class TaskStatus {
|
||||
DOWNLOAD_PENDING,
|
||||
DOWNLOAD_QUEUED,
|
||||
DOWNLOAD_FAILED,
|
||||
UPLOAD_PENDING,
|
||||
UPLOAD_QUEUED,
|
||||
UPLOAD_FAILED,
|
||||
UPLOAD_COMPLETE
|
||||
}
|
||||
|
||||
enum class BackupSelection {
|
||||
SELECTED,
|
||||
NONE,
|
||||
EXCLUDED
|
||||
}
|
||||
|
||||
enum class AvatarColor {
|
||||
PRIMARY,
|
||||
PINK,
|
||||
RED,
|
||||
YELLOW,
|
||||
BLUE,
|
||||
GREEN,
|
||||
PURPLE,
|
||||
ORANGE,
|
||||
GRAY,
|
||||
AMBER
|
||||
}
|
||||
|
||||
enum class AlbumUserRole {
|
||||
EDITOR,
|
||||
VIEWER
|
||||
}
|
||||
|
||||
enum class MemoryType {
|
||||
ON_THIS_DAY
|
||||
}
|
||||
|
||||
enum class AssetVisibility {
|
||||
TIMELINE,
|
||||
HIDDEN,
|
||||
ARCHIVE,
|
||||
LOCKED
|
||||
}
|
||||
|
||||
enum class SourceType(val value: String) {
|
||||
MACHINE_LEARNING("machine-learning"),
|
||||
EXIF("exif"),
|
||||
MANUAL("manual");
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): SourceType? = entries.find { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
enum class UploadMethod {
|
||||
MULTIPART,
|
||||
RESUMABLE
|
||||
}
|
||||
|
||||
enum class UploadErrorCode {
|
||||
UNKNOWN,
|
||||
ASSET_NOT_FOUND,
|
||||
FILE_NOT_FOUND,
|
||||
RESOURCE_NOT_FOUND,
|
||||
INVALID_RESOURCE,
|
||||
ENCODING_FAILED,
|
||||
WRITE_FAILED,
|
||||
NOT_ENOUGH_SPACE,
|
||||
NETWORK_ERROR,
|
||||
PHOTOS_INTERNAL_ERROR,
|
||||
PHOTOS_UNKNOWN_ERROR,
|
||||
NO_SERVER_URL,
|
||||
NO_DEVICE_ID,
|
||||
NO_ACCESS_TOKEN,
|
||||
INTERRUPTED,
|
||||
CANCELLED,
|
||||
DOWNLOAD_STALLED,
|
||||
FORCE_QUIT,
|
||||
OUT_OF_RESOURCES,
|
||||
BACKGROUND_UPDATES_DISABLED,
|
||||
UPLOAD_TIMEOUT,
|
||||
ICLOUD_RATE_LIMIT,
|
||||
ICLOUD_THROTTLED,
|
||||
INVALID_SERVER_RESPONSE,
|
||||
}
|
||||
|
||||
enum class AssetType {
|
||||
OTHER,
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
AUDIO
|
||||
}
|
||||
|
||||
enum class EndpointStatus(val value: String) {
|
||||
LOADING("loading"),
|
||||
VALID("valid"),
|
||||
ERROR("error"),
|
||||
UNKNOWN("unknown");
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): EndpointStatus? = entries.find { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoint data class
|
||||
data class Endpoint(
|
||||
val url: String,
|
||||
val status: EndpointStatus
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import app.alextran.immich.upload.TaskConfig
|
||||
import java.util.Date
|
||||
|
||||
@Dao
|
||||
interface LocalAssetDao {
|
||||
@Query("""
|
||||
SELECT a.id, a.type FROM local_asset_entity a
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM local_album_asset_entity laa
|
||||
INNER JOIN local_album_entity la ON laa.album_id = la.id
|
||||
WHERE laa.asset_id = a.id
|
||||
AND la.backup_selection = 0 -- selected
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM local_album_asset_entity laa2
|
||||
INNER JOIN local_album_entity la2 ON laa2.album_id = la2.id
|
||||
WHERE laa2.asset_id = a.id
|
||||
AND la2.backup_selection = 2 -- excluded
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity ra
|
||||
WHERE ra.checksum = a.checksum
|
||||
AND ra.owner_id = (SELECT string_value FROM store_entity WHERE id = 14) -- current_user
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM upload_tasks ut
|
||||
WHERE ut.local_id = a.id
|
||||
)
|
||||
LIMIT :limit
|
||||
""")
|
||||
suspend fun getCandidatesForBackup(limit: Int): List<BackupCandidate>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface StoreDao {
|
||||
@Query("SELECT * FROM store_entity WHERE id = :key")
|
||||
suspend fun get(key: StoreKey): Store?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(store: Store)
|
||||
|
||||
// Extension functions for type-safe access
|
||||
suspend fun <T> get(
|
||||
typedKey: TypedStoreKey<T>,
|
||||
storage: StorageType<T>
|
||||
): T? {
|
||||
val store = get(typedKey.key) ?: return null
|
||||
|
||||
return when (storage) {
|
||||
is StorageType.IntStorage,
|
||||
is StorageType.BoolStorage,
|
||||
is StorageType.DateStorage -> {
|
||||
store.intValue?.let { storage.fromDb(it) }
|
||||
}
|
||||
else -> {
|
||||
store.stringValue?.let { storage.fromDb(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> set(
|
||||
typedKey: TypedStoreKey<T>,
|
||||
value: T,
|
||||
storage: StorageType<T>
|
||||
) {
|
||||
val dbValue = storage.toDb(value)
|
||||
|
||||
val store = when (storage) {
|
||||
is StorageType.IntStorage,
|
||||
is StorageType.BoolStorage,
|
||||
is StorageType.DateStorage -> {
|
||||
Store(
|
||||
id = typedKey.key,
|
||||
stringValue = null,
|
||||
intValue = dbValue as Int
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Store(
|
||||
id = typedKey.key,
|
||||
stringValue = dbValue as String,
|
||||
intValue = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
insert(store)
|
||||
}
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface UploadTaskDao {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertAll(tasks: List<UploadTask>)
|
||||
|
||||
@Query("""
|
||||
SELECT id FROM upload_tasks
|
||||
WHERE status IN (:statuses)
|
||||
""")
|
||||
suspend fun getTaskIdsByStatus(statuses: List<TaskStatus>): List<Long>
|
||||
|
||||
@Query("""
|
||||
UPDATE upload_tasks
|
||||
SET status = 3, -- upload_pending
|
||||
file_path = NULL,
|
||||
attempts = 0
|
||||
WHERE id IN (:taskIds)
|
||||
""")
|
||||
suspend fun resetOrphanedTasks(taskIds: List<Long>)
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
t.attempts,
|
||||
a.checksum,
|
||||
a.created_at as createdAt,
|
||||
a.name as fileName,
|
||||
t.file_path as filePath,
|
||||
a.is_favorite as isFavorite,
|
||||
a.id as localId,
|
||||
t.priority,
|
||||
t.id as taskId,
|
||||
a.type,
|
||||
a.updated_at as updatedAt
|
||||
FROM upload_tasks t
|
||||
INNER JOIN local_asset_entity a ON t.local_id = a.id
|
||||
WHERE t.status = 3 -- upload_pending
|
||||
AND t.attempts < :maxAttempts
|
||||
AND a.checksum IS NOT NULL
|
||||
AND (t.retry_after IS NULL OR t.retry_after <= :currentTime)
|
||||
ORDER BY t.priority DESC, t.created_at ASC
|
||||
LIMIT :limit
|
||||
""")
|
||||
suspend fun getTasksForUpload(limit: Int, maxAttempts: Int = TaskConfig.MAX_ATTEMPTS, currentTime: Long = System.currentTimeMillis() / 1000): List<LocalAssetTaskData>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM upload_tasks WHERE status = 3 LIMIT 1)") // upload_pending
|
||||
suspend fun hasPendingTasks(): Boolean
|
||||
|
||||
@Query("""
|
||||
UPDATE upload_tasks
|
||||
SET attempts = :attempts,
|
||||
last_error = :errorCode,
|
||||
status = :status,
|
||||
retry_after = :retryAfter
|
||||
WHERE id = :taskId
|
||||
""")
|
||||
suspend fun updateTaskAfterFailure(
|
||||
taskId: Long,
|
||||
attempts: Int,
|
||||
errorCode: UploadErrorCode,
|
||||
status: TaskStatus,
|
||||
retryAfter: Date?
|
||||
)
|
||||
|
||||
@Query("UPDATE upload_tasks SET status = :status WHERE id = :id")
|
||||
suspend fun updateStatus(id: Long, status: TaskStatus)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface UploadTaskStatDao {
|
||||
@Query("SELECT * FROM upload_task_stats")
|
||||
suspend fun getStats(): UploadTaskStat?
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import com.google.gson.Gson
|
||||
import java.net.URL
|
||||
import java.util.Date
|
||||
|
||||
// Sealed interface representing storage types
|
||||
sealed interface StorageType<T> {
|
||||
fun toDb(value: T): Any
|
||||
fun fromDb(value: Any): T
|
||||
|
||||
data object IntStorage : StorageType<Int> {
|
||||
override fun toDb(value: Int) = value
|
||||
override fun fromDb(value: Any) = value as Int
|
||||
}
|
||||
|
||||
data object BoolStorage : StorageType<Boolean> {
|
||||
override fun toDb(value: Boolean) = if (value) 1 else 0
|
||||
override fun fromDb(value: Any) = (value as Int) == 1
|
||||
}
|
||||
|
||||
data object StringStorage : StorageType<String> {
|
||||
override fun toDb(value: String) = value
|
||||
override fun fromDb(value: Any) = value as String
|
||||
}
|
||||
|
||||
data object DateStorage : StorageType<Date> {
|
||||
override fun toDb(value: Date) = value.time / 1000
|
||||
override fun fromDb(value: Any) = Date((value as Long) * 1000)
|
||||
}
|
||||
|
||||
data object UrlStorage : StorageType<URL> {
|
||||
override fun toDb(value: URL) = value.toString()
|
||||
override fun fromDb(value: Any) = URL(value as String)
|
||||
}
|
||||
|
||||
class JsonStorage<T>(
|
||||
private val clazz: Class<T>,
|
||||
private val gson: Gson = Gson()
|
||||
) : StorageType<T> {
|
||||
override fun toDb(value: T) = gson.toJson(value)
|
||||
override fun fromDb(value: Any) = gson.fromJson(value as String, clazz)
|
||||
}
|
||||
}
|
||||
|
||||
// Typed key wrapper
|
||||
@JvmInline
|
||||
value class TypedStoreKey<T>(val key: StoreKey) {
|
||||
companion object {
|
||||
// Factory methods for type-safe key creation
|
||||
inline fun <reified T> of(key: StoreKey): TypedStoreKey<T> = TypedStoreKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Registry mapping keys to their storage types
|
||||
object StoreRegistry {
|
||||
private val intKeys = setOf(
|
||||
StoreKey.VERSION,
|
||||
StoreKey.DEVICE_ID_HASH,
|
||||
StoreKey.BACKUP_TRIGGER_DELAY
|
||||
)
|
||||
|
||||
private val stringKeys = setOf(
|
||||
StoreKey.CURRENT_USER,
|
||||
StoreKey.DEVICE_ID,
|
||||
StoreKey.ACCESS_TOKEN
|
||||
)
|
||||
|
||||
fun usesIntStorage(key: StoreKey): Boolean = key in intKeys
|
||||
fun usesStringStorage(key: StoreKey): Boolean = key in stringKeys
|
||||
}
|
||||
|
||||
// Storage type registry for automatic selection
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
object StorageTypes {
|
||||
inline fun <reified T> get(): StorageType<T> = when (T::class) {
|
||||
Int::class -> StorageType.IntStorage as StorageType<T>
|
||||
Boolean::class -> StorageType.BoolStorage as StorageType<T>
|
||||
String::class -> StorageType.StringStorage as StorageType<T>
|
||||
Date::class -> StorageType.DateStorage as StorageType<T>
|
||||
URL::class -> StorageType.UrlStorage as StorageType<T>
|
||||
else -> StorageType.JsonStorage(T::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified extension functions with automatic storage
|
||||
suspend inline fun <reified T> StoreDao.get(typedKey: TypedStoreKey<T>): T? {
|
||||
return get(typedKey, StorageTypes.get<T>())
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> StoreDao.set(typedKey: TypedStoreKey<T>, value: T) {
|
||||
set(typedKey, value, StorageTypes.get<T>())
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import androidx.room.*
|
||||
import java.net.URL
|
||||
import java.util.Date
|
||||
|
||||
@Entity(tableName = "asset_face_entity")
|
||||
data class AssetFace(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
@ColumnInfo(name = "person_id")
|
||||
val personId: String?,
|
||||
@ColumnInfo(name = "image_width")
|
||||
val imageWidth: Int,
|
||||
@ColumnInfo(name = "image_height")
|
||||
val imageHeight: Int,
|
||||
@ColumnInfo(name = "bounding_box_x1")
|
||||
val boundingBoxX1: Int,
|
||||
@ColumnInfo(name = "bounding_box_y1")
|
||||
val boundingBoxY1: Int,
|
||||
@ColumnInfo(name = "bounding_box_x2")
|
||||
val boundingBoxX2: Int,
|
||||
@ColumnInfo(name = "bounding_box_y2")
|
||||
val boundingBoxY2: Int,
|
||||
@ColumnInfo(name = "source_type")
|
||||
val sourceType: SourceType
|
||||
)
|
||||
|
||||
@Entity(tableName = "auth_user_entity")
|
||||
data class AuthUser(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
val email: String,
|
||||
@ColumnInfo(name = "is_admin")
|
||||
val isAdmin: Boolean,
|
||||
@ColumnInfo(name = "has_profile_image")
|
||||
val hasProfileImage: Boolean,
|
||||
@ColumnInfo(name = "profile_changed_at")
|
||||
val profileChangedAt: Date,
|
||||
@ColumnInfo(name = "avatar_color")
|
||||
val avatarColor: AvatarColor,
|
||||
@ColumnInfo(name = "quota_size_in_bytes")
|
||||
val quotaSizeInBytes: Int,
|
||||
@ColumnInfo(name = "quota_usage_in_bytes")
|
||||
val quotaUsageInBytes: Int,
|
||||
@ColumnInfo(name = "pin_code")
|
||||
val pinCode: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "local_album_entity")
|
||||
data class LocalAlbum(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "backup_selection")
|
||||
val backupSelection: BackupSelection,
|
||||
@ColumnInfo(name = "linked_remote_album_id")
|
||||
val linkedRemoteAlbumId: String?,
|
||||
@ColumnInfo(name = "marker")
|
||||
val marker: Boolean?,
|
||||
val name: String,
|
||||
@ColumnInfo(name = "is_ios_shared_album")
|
||||
val isIosSharedAlbum: Boolean,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "local_album_asset_entity",
|
||||
primaryKeys = ["asset_id", "album_id"]
|
||||
)
|
||||
data class LocalAlbumAsset(
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
@ColumnInfo(name = "album_id")
|
||||
val albumId: String,
|
||||
@ColumnInfo(name = "marker")
|
||||
val marker: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "local_asset_entity")
|
||||
data class LocalAsset(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val checksum: String?,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "duration_in_seconds")
|
||||
val durationInSeconds: Int?,
|
||||
val height: Int?,
|
||||
@ColumnInfo(name = "is_favorite")
|
||||
val isFavorite: Boolean,
|
||||
val name: String,
|
||||
val orientation: String,
|
||||
val type: AssetType,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date,
|
||||
val width: Int?
|
||||
)
|
||||
|
||||
data class BackupCandidate(
|
||||
val id: String,
|
||||
val type: AssetType
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "memory_asset_entity",
|
||||
primaryKeys = ["asset_id", "album_id"]
|
||||
)
|
||||
data class MemoryAsset(
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
@ColumnInfo(name = "album_id")
|
||||
val albumId: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "memory_entity")
|
||||
data class Memory(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date,
|
||||
@ColumnInfo(name = "deleted_at")
|
||||
val deletedAt: Date?,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
val type: MemoryType,
|
||||
val data: String,
|
||||
@ColumnInfo(name = "is_saved")
|
||||
val isSaved: Boolean,
|
||||
@ColumnInfo(name = "memory_at")
|
||||
val memoryAt: Date,
|
||||
@ColumnInfo(name = "seen_at")
|
||||
val seenAt: Date?,
|
||||
@ColumnInfo(name = "show_at")
|
||||
val showAt: Date?,
|
||||
@ColumnInfo(name = "hide_at")
|
||||
val hideAt: Date?
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "partner_entity",
|
||||
primaryKeys = ["shared_by_id", "shared_with_id"]
|
||||
)
|
||||
data class Partner(
|
||||
@ColumnInfo(name = "shared_by_id")
|
||||
val sharedById: String,
|
||||
@ColumnInfo(name = "shared_with_id")
|
||||
val sharedWithId: String,
|
||||
@ColumnInfo(name = "in_timeline")
|
||||
val inTimeline: Boolean
|
||||
)
|
||||
|
||||
@Entity(tableName = "person_entity")
|
||||
data class Person(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
val name: String,
|
||||
@ColumnInfo(name = "face_asset_id")
|
||||
val faceAssetId: String?,
|
||||
@ColumnInfo(name = "is_favorite")
|
||||
val isFavorite: Boolean,
|
||||
@ColumnInfo(name = "is_hidden")
|
||||
val isHidden: Boolean,
|
||||
val color: String?,
|
||||
@ColumnInfo(name = "birth_date")
|
||||
val birthDate: Date?
|
||||
)
|
||||
|
||||
@Entity(tableName = "remote_album_entity")
|
||||
data class RemoteAlbum(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
val description: String?,
|
||||
@ColumnInfo(name = "is_activity_enabled")
|
||||
val isActivityEnabled: Boolean,
|
||||
val name: String,
|
||||
val order: Int,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
@ColumnInfo(name = "thumbnail_asset_id")
|
||||
val thumbnailAssetId: String?,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "remote_album_asset_entity",
|
||||
primaryKeys = ["asset_id", "album_id"]
|
||||
)
|
||||
data class RemoteAlbumAsset(
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
@ColumnInfo(name = "album_id")
|
||||
val albumId: String
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "remote_album_user_entity",
|
||||
primaryKeys = ["album_id", "user_id"]
|
||||
)
|
||||
data class RemoteAlbumUser(
|
||||
@ColumnInfo(name = "album_id")
|
||||
val albumId: String,
|
||||
@ColumnInfo(name = "user_id")
|
||||
val userId: String,
|
||||
val role: AlbumUserRole
|
||||
)
|
||||
|
||||
@Entity(tableName = "remote_asset_entity")
|
||||
data class RemoteAsset(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val checksum: String,
|
||||
@ColumnInfo(name = "is_favorite")
|
||||
val isFavorite: Boolean,
|
||||
@ColumnInfo(name = "deleted_at")
|
||||
val deletedAt: Date?,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
@ColumnInfo(name = "local_date_time")
|
||||
val localDateTime: Date?,
|
||||
@ColumnInfo(name = "thumb_hash")
|
||||
val thumbHash: String?,
|
||||
@ColumnInfo(name = "library_id")
|
||||
val libraryId: String?,
|
||||
@ColumnInfo(name = "live_photo_video_id")
|
||||
val livePhotoVideoId: String?,
|
||||
@ColumnInfo(name = "stack_id")
|
||||
val stackId: String?,
|
||||
val visibility: AssetVisibility
|
||||
)
|
||||
|
||||
@Entity(tableName = "remote_exif_entity")
|
||||
data class RemoteExif(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
val city: String?,
|
||||
val state: String?,
|
||||
val country: String?,
|
||||
@ColumnInfo(name = "date_time_original")
|
||||
val dateTimeOriginal: Date?,
|
||||
val description: String?,
|
||||
val height: Int?,
|
||||
val width: Int?,
|
||||
@ColumnInfo(name = "exposure_time")
|
||||
val exposureTime: String?,
|
||||
@ColumnInfo(name = "f_number")
|
||||
val fNumber: Double?,
|
||||
@ColumnInfo(name = "file_size")
|
||||
val fileSize: Int?,
|
||||
@ColumnInfo(name = "focal_length")
|
||||
val focalLength: Double?,
|
||||
val latitude: Double?,
|
||||
val longitude: Double?,
|
||||
val iso: Int?,
|
||||
val make: String?,
|
||||
val model: String?,
|
||||
val lens: String?,
|
||||
val orientation: String?,
|
||||
@ColumnInfo(name = "time_zone")
|
||||
val timeZone: String?,
|
||||
val rating: Int?,
|
||||
@ColumnInfo(name = "projection_type")
|
||||
val projectionType: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "stack_entity")
|
||||
data class Stack(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
@ColumnInfo(name = "primary_asset_id")
|
||||
val primaryAssetId: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "store_entity")
|
||||
data class Store(
|
||||
@PrimaryKey
|
||||
val id: StoreKey,
|
||||
@ColumnInfo(name = "string_value")
|
||||
val stringValue: String?,
|
||||
@ColumnInfo(name = "int_value")
|
||||
val intValue: Int?
|
||||
)
|
||||
|
||||
@Entity(tableName = "upload_task_entity")
|
||||
data class UploadTask(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val attempts: Int,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "file_path")
|
||||
val filePath: URL?,
|
||||
@ColumnInfo(name = "is_live_photo")
|
||||
val isLivePhoto: Boolean?,
|
||||
@ColumnInfo(name = "last_error")
|
||||
val lastError: UploadErrorCode?,
|
||||
@ColumnInfo(name = "live_photo_video_id")
|
||||
val livePhotoVideoId: String?,
|
||||
@ColumnInfo(name = "local_id")
|
||||
val localId: String,
|
||||
val method: UploadMethod,
|
||||
val priority: Float,
|
||||
@ColumnInfo(name = "retry_after")
|
||||
val retryAfter: Date?,
|
||||
val status: TaskStatus
|
||||
)
|
||||
|
||||
// Data class for query results
|
||||
data class LocalAssetTaskData(
|
||||
val attempts: Int,
|
||||
val checksum: String,
|
||||
val createdAt: Date,
|
||||
val fileName: String,
|
||||
val filePath: URL?,
|
||||
val isFavorite: Boolean,
|
||||
val localId: String,
|
||||
val priority: Float,
|
||||
val taskId: Long,
|
||||
val type: AssetType,
|
||||
val updatedAt: Date
|
||||
)
|
||||
|
||||
@Entity(tableName = "upload_task_stats")
|
||||
data class UploadTaskStat(
|
||||
@ColumnInfo(name = "pending_downloads")
|
||||
val pendingDownloads: Int,
|
||||
@ColumnInfo(name = "pending_uploads")
|
||||
val pendingUploads: Int,
|
||||
@ColumnInfo(name = "queued_downloads")
|
||||
val queuedDownloads: Int,
|
||||
@ColumnInfo(name = "queued_uploads")
|
||||
val queuedUploads: Int,
|
||||
@ColumnInfo(name = "failed_downloads")
|
||||
val failedDownloads: Int,
|
||||
@ColumnInfo(name = "failed_uploads")
|
||||
val failedUploads: Int,
|
||||
@ColumnInfo(name = "completed_uploads")
|
||||
val completedUploads: Int
|
||||
)
|
||||
|
||||
@Entity(tableName = "user_entity")
|
||||
data class User(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
val email: String,
|
||||
@ColumnInfo(name = "has_profile_image")
|
||||
val hasProfileImage: Boolean,
|
||||
@ColumnInfo(name = "profile_changed_at")
|
||||
val profileChangedAt: Date,
|
||||
@ColumnInfo(name = "avatar_color")
|
||||
val avatarColor: AvatarColor
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "user_metadata_entity",
|
||||
primaryKeys = ["user_id", "key"]
|
||||
)
|
||||
data class UserMetadata(
|
||||
@ColumnInfo(name = "user_id")
|
||||
val userId: String,
|
||||
val key: Date,
|
||||
val value: ByteArray
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as UserMetadata
|
||||
|
||||
if (userId != other.userId) return false
|
||||
if (key != other.key) return false
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = userId.hashCode()
|
||||
result = 31 * result + key.hashCode()
|
||||
result = 31 * result + value.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -89,10 +89,7 @@ data class PlatformAsset (
|
||||
val height: Long? = null,
|
||||
val durationInSeconds: Long,
|
||||
val orientation: Long,
|
||||
val isFavorite: Boolean,
|
||||
val adjustmentTime: Long? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null
|
||||
val isFavorite: Boolean
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
@@ -107,10 +104,7 @@ data class PlatformAsset (
|
||||
val durationInSeconds = pigeonVar_list[7] as Long
|
||||
val orientation = pigeonVar_list[8] as Long
|
||||
val isFavorite = pigeonVar_list[9] as Boolean
|
||||
val adjustmentTime = pigeonVar_list[10] as Long?
|
||||
val latitude = pigeonVar_list[11] as Double?
|
||||
val longitude = pigeonVar_list[12] as Double?
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
@@ -125,9 +119,6 @@ data class PlatformAsset (
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package app.alextran.immich.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
|
||||
object NetworkMonitor {
|
||||
@Volatile
|
||||
private var isConnected = false
|
||||
|
||||
@Volatile
|
||||
private var isWifi = false
|
||||
|
||||
fun initialize(context: Context) {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
connectivityManager.registerNetworkCallback(networkRequest, object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
isConnected = true
|
||||
checkWifi(connectivityManager, network)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
isConnected = false
|
||||
isWifi = false
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||
checkWifi(connectivityManager, network)
|
||||
}
|
||||
|
||||
private fun checkWifi(cm: ConnectivityManager, network: Network) {
|
||||
val capabilities = cm.getNetworkCapabilities(network)
|
||||
isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun isConnected(): Boolean = isConnected
|
||||
|
||||
fun isWifiConnected(context: Context): Boolean {
|
||||
if (!isConnected) return false
|
||||
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package app.alextran.immich.upload
|
||||
|
||||
object TaskConfig {
|
||||
const val MAX_ATTEMPTS = 3
|
||||
const val MAX_PENDING_DOWNLOADS = 10
|
||||
const val MAX_PENDING_UPLOADS = 10
|
||||
const val MAX_ACTIVE_UPLOADS = 3
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.upload
|
||||
|
||||
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 UploadTaskPigeonUtils {
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
return a.size == b.size && a.all {
|
||||
(b as Map<Any?, Any?>).containsKey(it.key) &&
|
||||
deepEquals(it.value, b[it.key])
|
||||
}
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
enum class UploadApiErrorCode(val raw: Int) {
|
||||
UNKNOWN(0),
|
||||
ASSET_NOT_FOUND(1),
|
||||
FILE_NOT_FOUND(2),
|
||||
RESOURCE_NOT_FOUND(3),
|
||||
INVALID_RESOURCE(4),
|
||||
ENCODING_FAILED(5),
|
||||
WRITE_FAILED(6),
|
||||
NOT_ENOUGH_SPACE(7),
|
||||
NETWORK_ERROR(8),
|
||||
PHOTOS_INTERNAL_ERROR(9),
|
||||
PHOTOS_UNKNOWN_ERROR(10),
|
||||
INTERRUPTED(11),
|
||||
CANCELLED(12),
|
||||
DOWNLOAD_STALLED(13),
|
||||
FORCE_QUIT(14),
|
||||
OUT_OF_RESOURCES(15),
|
||||
BACKGROUND_UPDATES_DISABLED(16),
|
||||
UPLOAD_TIMEOUT(17),
|
||||
I_CLOUD_RATE_LIMIT(18),
|
||||
I_CLOUD_THROTTLED(19),
|
||||
INVALID_RESPONSE(20),
|
||||
BAD_REQUEST(21),
|
||||
INTERNAL_SERVER_ERROR(22),
|
||||
UNAUTHORIZED(23);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): UploadApiErrorCode? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class UploadApiStatus(val raw: Int) {
|
||||
DOWNLOAD_PENDING(0),
|
||||
DOWNLOAD_QUEUED(1),
|
||||
DOWNLOAD_FAILED(2),
|
||||
UPLOAD_PENDING(3),
|
||||
UPLOAD_QUEUED(4),
|
||||
UPLOAD_FAILED(5),
|
||||
UPLOAD_COMPLETE(6),
|
||||
UPLOAD_SKIPPED(7);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): UploadApiStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class UploadApiTaskStatus (
|
||||
val id: String,
|
||||
val filename: String,
|
||||
val status: UploadApiStatus,
|
||||
val errorCode: UploadApiErrorCode? = null,
|
||||
val httpStatusCode: Long? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): UploadApiTaskStatus {
|
||||
val id = pigeonVar_list[0] as String
|
||||
val filename = pigeonVar_list[1] as String
|
||||
val status = pigeonVar_list[2] as UploadApiStatus
|
||||
val errorCode = pigeonVar_list[3] as UploadApiErrorCode?
|
||||
val httpStatusCode = pigeonVar_list[4] as Long?
|
||||
return UploadApiTaskStatus(id, filename, status, errorCode, httpStatusCode)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
id,
|
||||
filename,
|
||||
status,
|
||||
errorCode,
|
||||
httpStatusCode,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is UploadApiTaskStatus) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return UploadTaskPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class UploadApiTaskProgress (
|
||||
val id: String,
|
||||
val progress: Double,
|
||||
val speed: Double? = null,
|
||||
val totalBytes: Long? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): UploadApiTaskProgress {
|
||||
val id = pigeonVar_list[0] as String
|
||||
val progress = pigeonVar_list[1] as Double
|
||||
val speed = pigeonVar_list[2] as Double?
|
||||
val totalBytes = pigeonVar_list[3] as Long?
|
||||
return UploadApiTaskProgress(id, progress, speed, totalBytes)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
id,
|
||||
progress,
|
||||
speed,
|
||||
totalBytes,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is UploadApiTaskProgress) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return UploadTaskPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
private open class UploadTaskPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
UploadApiErrorCode.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
UploadApiStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
UploadApiTaskStatus.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
UploadApiTaskProgress.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is UploadApiErrorCode -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
is UploadApiStatus -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
is UploadApiTaskStatus -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is UploadApiTaskProgress -> {
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val UploadTaskPigeonMethodCodec = StandardMethodCodec(UploadTaskPigeonCodec())
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface UploadApi {
|
||||
fun initialize(callback: (Result<Unit>) -> Unit)
|
||||
fun refresh(callback: (Result<Unit>) -> Unit)
|
||||
fun cancelAll(callback: (Result<Unit>) -> Unit)
|
||||
fun enqueueAssets(localIds: List<String>, callback: (Result<Unit>) -> Unit)
|
||||
fun enqueueFiles(paths: List<String>, callback: (Result<Unit>) -> Unit)
|
||||
fun onConfigChange(key: Long, callback: (Result<Unit>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by UploadApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
UploadTaskPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `UploadApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: UploadApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.initialize$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.initialize{ result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.refresh$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.refresh{ result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.cancelAll$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.cancelAll{ result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueAssets$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val localIdsArg = args[0] as List<String>
|
||||
api.enqueueAssets(localIdsArg) { result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueFiles$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val pathsArg = args[0] as List<String>
|
||||
api.enqueueFiles(pathsArg) { result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.onConfigChange$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val keyArg = args[0] as Long
|
||||
api.onConfigChange(keyArg) { result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadTaskPigeonStreamHandler<T>(
|
||||
val wrapper: UploadTaskPigeonEventChannelWrapper<T>
|
||||
) : EventChannel.StreamHandler {
|
||||
var pigeonSink: PigeonEventSink<T>? = null
|
||||
|
||||
override fun onListen(p0: Any?, sink: EventChannel.EventSink) {
|
||||
pigeonSink = PigeonEventSink<T>(sink)
|
||||
wrapper.onListen(p0, pigeonSink!!)
|
||||
}
|
||||
|
||||
override fun onCancel(p0: Any?) {
|
||||
pigeonSink = null
|
||||
wrapper.onCancel(p0)
|
||||
}
|
||||
}
|
||||
|
||||
interface UploadTaskPigeonEventChannelWrapper<T> {
|
||||
open fun onListen(p0: Any?, sink: PigeonEventSink<T>) {}
|
||||
|
||||
open fun onCancel(p0: Any?) {}
|
||||
}
|
||||
|
||||
class PigeonEventSink<T>(private val sink: EventChannel.EventSink) {
|
||||
fun success(value: T) {
|
||||
sink.success(value)
|
||||
}
|
||||
|
||||
fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
sink.error(errorCode, errorMessage, errorDetails)
|
||||
}
|
||||
|
||||
fun endOfStream() {
|
||||
sink.endOfStream()
|
||||
}
|
||||
}
|
||||
|
||||
abstract class StreamStatusStreamHandler : UploadTaskPigeonEventChannelWrapper<UploadApiTaskStatus> {
|
||||
companion object {
|
||||
fun register(messenger: BinaryMessenger, streamHandler: StreamStatusStreamHandler, instanceName: String = "") {
|
||||
var channelName: String = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamStatus"
|
||||
if (instanceName.isNotEmpty()) {
|
||||
channelName += ".$instanceName"
|
||||
}
|
||||
val internalStreamHandler = UploadTaskPigeonStreamHandler<UploadApiTaskStatus>(streamHandler)
|
||||
EventChannel(messenger, channelName, UploadTaskPigeonMethodCodec).setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class StreamProgressStreamHandler : UploadTaskPigeonEventChannelWrapper<UploadApiTaskProgress> {
|
||||
companion object {
|
||||
fun register(messenger: BinaryMessenger, streamHandler: StreamProgressStreamHandler, instanceName: String = "") {
|
||||
var channelName: String = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamProgress"
|
||||
if (instanceName.isNotEmpty()) {
|
||||
channelName += ".$instanceName"
|
||||
}
|
||||
val internalStreamHandler = UploadTaskPigeonStreamHandler<UploadApiTaskProgress>(streamHandler)
|
||||
EventChannel(messenger, channelName, UploadTaskPigeonMethodCodec).setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package app.alextran.immich.upload
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.*
|
||||
import app.alextran.immich.schema.AppDatabase
|
||||
import app.alextran.immich.schema.AssetType
|
||||
import app.alextran.immich.schema.StorageType
|
||||
import app.alextran.immich.schema.StoreKey
|
||||
import app.alextran.immich.schema.TaskStatus
|
||||
import app.alextran.immich.schema.UploadMethod
|
||||
import app.alextran.immich.schema.UploadTask
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.guava.await
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// TODO: this is almost entirely LLM-generated (ported from Swift), need to verify behavior
|
||||
class UploadTaskImpl(context: Context) : UploadApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private val db: AppDatabase = AppDatabase.getDatabase(ctx)
|
||||
private val workManager: WorkManager = WorkManager.getInstance(ctx)
|
||||
|
||||
@Volatile
|
||||
private var isInitialized = false
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun initialize(callback: (Result<Unit>) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
// Clean up orphaned tasks
|
||||
val activeWorkInfos = workManager.getWorkInfosByTag(UPLOAD_WORK_TAG).await()
|
||||
val activeTaskIds = activeWorkInfos
|
||||
.filter { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED }
|
||||
.mapNotNull {
|
||||
it.tags.find { tag -> tag.startsWith("task_") }?.substringAfter("task_")?.toLongOrNull()
|
||||
}
|
||||
.toSet()
|
||||
|
||||
db.uploadTaskDao().run {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Find tasks marked as queued but not actually running
|
||||
val dbQueuedIds = getTaskIdsByStatus(
|
||||
listOf(
|
||||
TaskStatus.DOWNLOAD_QUEUED,
|
||||
TaskStatus.UPLOAD_QUEUED,
|
||||
TaskStatus.UPLOAD_PENDING
|
||||
)
|
||||
)
|
||||
|
||||
val orphanIds = dbQueuedIds.filterNot { it in activeTaskIds }
|
||||
|
||||
if (orphanIds.isNotEmpty()) {
|
||||
resetOrphanedTasks(orphanIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp files
|
||||
val tempDir = getTempDirectory()
|
||||
tempDir.deleteRecursively()
|
||||
|
||||
isInitialized = true
|
||||
startBackup()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun refresh(callback: (Result<Unit>) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
startBackup()
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startBackup() {
|
||||
if (!isInitialized) return
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check if backup is enabled
|
||||
val backupEnabled = db.storeDao().get(StoreKey.enableBackup, StorageType.BoolStorage)
|
||||
if (backupEnabled != true) return@withContext
|
||||
|
||||
// Get upload statistics
|
||||
val stats = db.uploadTaskStatDao().getStats() ?: return@withContext
|
||||
val availableSlots = TaskConfig.MAX_PENDING_UPLOADS + TaskConfig.MAX_PENDING_DOWNLOADS -
|
||||
(stats.pendingDownloads + stats.queuedDownloads + stats.pendingUploads + stats.queuedUploads)
|
||||
|
||||
if (availableSlots <= 0) return@withContext
|
||||
|
||||
// Find candidate assets for backup
|
||||
val candidates = db.localAssetDao().getCandidatesForBackup(availableSlots)
|
||||
|
||||
if (candidates.isEmpty()) return@withContext
|
||||
|
||||
// Create upload tasks for candidates
|
||||
db.uploadTaskDao().insertAll(candidates.map { candidate ->
|
||||
UploadTask(
|
||||
attempts = 0,
|
||||
createdAt = Date(),
|
||||
filePath = null,
|
||||
isLivePhoto = null,
|
||||
lastError = null,
|
||||
livePhotoVideoId = null,
|
||||
localId = candidate.id,
|
||||
method = UploadMethod.MULTIPART,
|
||||
priority = when (candidate.type) {
|
||||
AssetType.IMAGE -> 0.5f
|
||||
else -> 0.3f
|
||||
},
|
||||
retryAfter = null,
|
||||
status = TaskStatus.UPLOAD_PENDING
|
||||
)
|
||||
})
|
||||
|
||||
// Start upload workers
|
||||
enqueueUploadWorkers()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Backup queue error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueUploadWorkers() {
|
||||
// Create constraints
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
// Create work request
|
||||
val uploadWorkRequest = OneTimeWorkRequestBuilder<UploadWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(UPLOAD_WORK_TAG)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
UPLOAD_WORK_NAME,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
uploadWorkRequest
|
||||
)
|
||||
}
|
||||
|
||||
private fun getTempDirectory(): java.io.File {
|
||||
return java.io.File(ctx.cacheDir, "upload_temp").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UploadTaskImpl"
|
||||
private const val UPLOAD_WORK_TAG = "immich_upload"
|
||||
private const val UPLOAD_WORK_NAME = "immich_upload_unique"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package app.alextran.immich.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import androidx.work.*
|
||||
import app.alextran.immich.schema.AppDatabase
|
||||
import app.alextran.immich.schema.AssetType
|
||||
import app.alextran.immich.schema.LocalAssetTaskData
|
||||
import app.alextran.immich.schema.StorageType
|
||||
import app.alextran.immich.schema.StoreKey
|
||||
import app.alextran.immich.schema.TaskStatus
|
||||
import app.alextran.immich.schema.UploadErrorCode
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UploadWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
private val db = AppDatabase.getDatabase(applicationContext)
|
||||
private val client = createOkHttpClient()
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check if backup is enabled
|
||||
val backupEnabled = db.storeDao().get(StoreKey.enableBackup, StorageType.BoolStorage)
|
||||
if (backupEnabled != true) {
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
// Get pending upload tasks
|
||||
val tasks = db.uploadTaskDao().getTasksForUpload(TaskConfig.MAX_ACTIVE_UPLOADS)
|
||||
|
||||
if (tasks.isEmpty()) {
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
// Process tasks concurrently
|
||||
val results = tasks.map { task ->
|
||||
async { processUploadTask(task) }
|
||||
}.awaitAll()
|
||||
|
||||
// Check if we should continue processing
|
||||
val hasMore = db.uploadTaskDao().hasPendingTasks()
|
||||
|
||||
if (hasMore) {
|
||||
// Schedule next batch
|
||||
enqueueNextBatch()
|
||||
}
|
||||
|
||||
// Determine result based on processing outcomes
|
||||
when {
|
||||
results.all { it } -> Result.success()
|
||||
results.any { it } -> Result.success() // Partial success
|
||||
else -> Result.retry()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Upload worker error", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processUploadTask(task: LocalAssetTaskData): Boolean {
|
||||
return try {
|
||||
// Get asset from MediaStore
|
||||
val assetUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
.buildUpon()
|
||||
.appendPath(task.localId)
|
||||
.build()
|
||||
|
||||
val cursor = applicationContext.contentResolver.query(
|
||||
assetUri,
|
||||
arrayOf(MediaStore.Images.Media.DATA),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
) ?: return handleFailure(task, UploadErrorCode.ASSET_NOT_FOUND)
|
||||
|
||||
val filePath = cursor.use {
|
||||
if (it.moveToFirst()) {
|
||||
it.getString(it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
|
||||
} else null
|
||||
} ?: return handleFailure(task, UploadErrorCode.ASSET_NOT_FOUND)
|
||||
|
||||
val file = File(filePath)
|
||||
if (!file.exists()) {
|
||||
return handleFailure(task, UploadErrorCode.FILE_NOT_FOUND)
|
||||
}
|
||||
|
||||
// Get server configuration
|
||||
val serverUrl = db.storeDao().get(StoreKey.serverEndpoint, StorageType.UrlStorage)
|
||||
?: return handleFailure(task, UploadErrorCode.NO_SERVER_URL)
|
||||
val accessToken = db.storeDao().get(StoreKey.accessToken, StorageType.StringStorage)
|
||||
?: return handleFailure(task, UploadErrorCode.NO_ACCESS_TOKEN)
|
||||
val deviceId = db.storeDao().get(StoreKey.deviceId, StorageType.StringStorage)
|
||||
?: return handleFailure(task, UploadErrorCode.NO_DEVICE_ID)
|
||||
|
||||
// Check network constraints
|
||||
val useWifiOnly = when (task.type) {
|
||||
AssetType.IMAGE -> db.storeDao().get(StoreKey.useWifiForUploadPhotos, StorageType.BoolStorage) ?: false
|
||||
AssetType.VIDEO -> db.storeDao().get(StoreKey.useWifiForUploadVideos, StorageType.BoolStorage) ?: false
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (useWifiOnly && !NetworkMonitor.isWifiConnected(applicationContext)) {
|
||||
// Wait for WiFi
|
||||
return true
|
||||
}
|
||||
|
||||
// Update task status
|
||||
db.uploadTaskDao().updateStatus(task.taskId, TaskStatus.UPLOAD_QUEUED)
|
||||
|
||||
// Perform upload
|
||||
uploadFile(task, file, serverUrl, accessToken, deviceId)
|
||||
|
||||
// Mark as complete
|
||||
db.uploadTaskDao().updateStatus(task.taskId, TaskStatus.UPLOAD_COMPLETE)
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Upload task ${task.taskId} failed", e)
|
||||
handleFailure(task, UploadErrorCode.UNKNOWN)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadFile(
|
||||
task: LocalAssetTaskData,
|
||||
file: File,
|
||||
serverUrl: URL,
|
||||
accessToken: String,
|
||||
deviceId: String
|
||||
) {
|
||||
val requestBody = createMultipartBody(task, file, deviceId)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl}/api/upload")
|
||||
.post(requestBody)
|
||||
.header("x-immich-user-token", accessToken)
|
||||
.tag(task.taskId)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Upload failed: ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMultipartBody(
|
||||
task: LocalAssetTaskData,
|
||||
file: File,
|
||||
deviceId: String
|
||||
): RequestBody {
|
||||
val boundary = "Boundary-${UUID.randomUUID()}"
|
||||
|
||||
return object : RequestBody() {
|
||||
override fun contentType() = "multipart/form-data; boundary=$boundary".toMediaType()
|
||||
|
||||
override fun writeTo(sink: okio.BufferedSink) {
|
||||
// Write form fields
|
||||
writeFormField(sink, boundary, "deviceAssetId", task.localId)
|
||||
writeFormField(sink, boundary, "deviceId", deviceId)
|
||||
writeFormField(sink, boundary, "fileCreatedAt", (task.createdAt.time / 1000).toString())
|
||||
writeFormField(sink, boundary, "fileModifiedAt", (task.updatedAt.time / 1000).toString())
|
||||
writeFormField(sink, boundary, "fileName", task.fileName)
|
||||
writeFormField(sink, boundary, "isFavorite", task.isFavorite.toString())
|
||||
|
||||
// Write file
|
||||
sink.writeUtf8("--$boundary\r\n")
|
||||
sink.writeUtf8("Content-Disposition: form-data; name=\"assetData\"; filename=\"asset\"\r\n")
|
||||
sink.writeUtf8("Content-Type: application/octet-stream\r\n\r\n")
|
||||
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
sink.write(buffer, 0, bytesRead)
|
||||
|
||||
// Report progress (simplified - could be enhanced with listeners)
|
||||
setProgressAsync(
|
||||
workDataOf(
|
||||
PROGRESS_TASK_ID to task.taskId,
|
||||
PROGRESS_BYTES to file.length()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sink.writeUtf8("\r\n--$boundary--\r\n")
|
||||
}
|
||||
|
||||
private fun writeFormField(sink: okio.BufferedSink, boundary: String, name: String, value: String) {
|
||||
sink.writeUtf8("--$boundary\r\n")
|
||||
sink.writeUtf8("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
|
||||
sink.writeUtf8(value)
|
||||
sink.writeUtf8("\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleFailure(task: LocalAssetTaskData, code: UploadErrorCode): Boolean {
|
||||
val newAttempts = task.attempts + 1
|
||||
val status = if (newAttempts >= TaskConfig.MAX_ATTEMPTS) {
|
||||
TaskStatus.UPLOAD_FAILED
|
||||
} else {
|
||||
TaskStatus.UPLOAD_PENDING
|
||||
}
|
||||
|
||||
val retryAfter = if (status == TaskStatus.UPLOAD_PENDING) {
|
||||
Date(System.currentTimeMillis() + (Math.pow(3.0, newAttempts.toDouble()) * 1000).toLong())
|
||||
} else null
|
||||
|
||||
db.uploadTaskDao().updateTaskAfterFailure(
|
||||
task.taskId,
|
||||
newAttempts,
|
||||
code,
|
||||
status,
|
||||
retryAfter
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun enqueueNextBatch() {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val nextWorkRequest = OneTimeWorkRequestBuilder<UploadWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(UPLOAD_WORK_TAG)
|
||||
.setInitialDelay(1, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(applicationContext)
|
||||
.enqueueUniqueWork(
|
||||
UPLOAD_WORK_NAME,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
nextWorkRequest
|
||||
)
|
||||
}
|
||||
|
||||
private fun createOkHttpClient(): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(300, TimeUnit.SECONDS)
|
||||
.writeTimeout(300, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UploadWorker"
|
||||
private const val UPLOAD_WORK_TAG = "immich_upload"
|
||||
private const val UPLOAD_WORK_NAME = "immich_upload_unique"
|
||||
const val PROGRESS_TASK_ID = "progress_task_id"
|
||||
const val PROGRESS_BYTES = "progress_bytes"
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,3 @@ tasks.register("clean", Delete) {
|
||||
tasks.named('wrapper') {
|
||||
distributionType = Wrapper.DistributionType.ALL
|
||||
}
|
||||
|
||||
|
||||
2
mobile/drift_schemas/main/drift_schema_v14.json
generated
2
mobile/drift_schemas/main/drift_schema_v14.json
generated
File diff suppressed because one or more lines are too long
@@ -32,7 +32,6 @@ target 'Runner' do
|
||||
use_modular_headers!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
|
||||
# share_handler addition start
|
||||
target 'ShareExtension' do
|
||||
inherit! :search_paths
|
||||
|
||||
@@ -88,9 +88,9 @@ PODS:
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SAMKeychain (1.5.3)
|
||||
- SDWebImage (5.21.0):
|
||||
- SDWebImage/Core (= 5.21.0)
|
||||
- SDWebImage/Core (5.21.0)
|
||||
- SDWebImage (5.21.3):
|
||||
- SDWebImage/Core (= 5.21.3)
|
||||
- SDWebImage/Core (5.21.3)
|
||||
- share_handler_ios (0.0.14):
|
||||
- Flutter
|
||||
- share_handler_ios/share_handler_ios_models (= 0.0.14)
|
||||
@@ -107,16 +107,16 @@ PODS:
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (3.49.1):
|
||||
- sqlite3/common (= 3.49.1)
|
||||
- sqlite3/common (3.49.1)
|
||||
- sqlite3/dbstatvtab (3.49.1):
|
||||
- sqlite3 (3.49.2):
|
||||
- sqlite3/common (= 3.49.2)
|
||||
- sqlite3/common (3.49.2)
|
||||
- sqlite3/dbstatvtab (3.49.2):
|
||||
- sqlite3/common
|
||||
- sqlite3/fts5 (3.49.1):
|
||||
- sqlite3/fts5 (3.49.2):
|
||||
- sqlite3/common
|
||||
- sqlite3/perf-threadsafe (3.49.1):
|
||||
- sqlite3/perf-threadsafe (3.49.2):
|
||||
- sqlite3/common
|
||||
- sqlite3/rtree (3.49.1):
|
||||
- sqlite3/rtree (3.49.2):
|
||||
- sqlite3/common
|
||||
- sqlite3_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
@@ -275,18 +275,18 @@ SPEC CHECKSUMS:
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
|
||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
||||
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
||||
sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1
|
||||
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
|
||||
PODFILE CHECKSUM: 95621706d175fee669455a5946a602e2a775019c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -77,6 +77,16 @@
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
FE4C52462EAFE736009EEB47 /* Embed ExtensionKit Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(EXTENSIONS_FOLDER_PATH)";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
);
|
||||
name = "Embed ExtensionKit Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -136,15 +146,11 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -156,10 +162,23 @@
|
||||
path = WidgetExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FE14355D2EC446E90009D5AC /* Upload */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Upload;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEA74CE22ED223690014C832 /* Repositories */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Repositories;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEB3BA112EBD52860081A5EB /* Schemas */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Schemas;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEE084F22EC172080045228E /* Schemas */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Schemas;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -267,7 +286,10 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FEA74CE22ED223690014C832 /* Repositories */,
|
||||
FE14355D2EC446E90009D5AC /* Upload */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
FEB3BA112EBD52860081A5EB /* Schemas */,
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||
@@ -345,6 +367,7 @@
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */,
|
||||
6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */,
|
||||
FE4C52462EAFE736009EEB47 /* Embed ExtensionKit Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -355,6 +378,9 @@
|
||||
fileSystemSynchronizedGroups = (
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
FE14355D2EC446E90009D5AC /* Upload */,
|
||||
FEA74CE22ED223690014C832 /* Repositories */,
|
||||
FEB3BA112EBD52860081A5EB /* Schemas */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
);
|
||||
name = Runner;
|
||||
@@ -407,7 +433,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 1640;
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
@@ -549,10 +575,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -581,10 +611,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -735,7 +769,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -744,7 +778,8 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.121.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D DEBUG -Xllvm -sil-disable-pass=performance-linker";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.profile;
|
||||
PRODUCT_NAME = "Immich-Profile";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -879,7 +914,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -888,7 +923,8 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.121.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D DEBUG -Xllvm -sil-disable-pass=performance-linker";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug;
|
||||
PRODUCT_NAME = "Immich-Debug";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -909,7 +945,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -918,7 +954,8 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.121.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -Xllvm -sil-disable-pass=performance-linker";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich;
|
||||
PRODUCT_NAME = Immich;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -942,7 +979,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -959,7 +996,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.Widget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug.Widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
@@ -985,7 +1022,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1001,7 +1038,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.Widget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.Widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -1025,7 +1062,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1041,7 +1078,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.Widget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.profile.Widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -1065,7 +1102,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1082,7 +1119,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1109,7 +1146,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1125,7 +1162,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1150,7 +1187,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1166,7 +1203,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertakev.immich.profile.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -28,15 +28,6 @@
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-case-paths",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"state" : {
|
||||
"revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
|
||||
"version" : "1.7.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-clocks",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -132,8 +123,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-structured-queries",
|
||||
"state" : {
|
||||
"revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756",
|
||||
"version" : "0.25.0"
|
||||
"revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9",
|
||||
"version" : "0.25.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -145,15 +136,6 @@
|
||||
"version" : "602.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-tagged",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-tagged",
|
||||
"state" : {
|
||||
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
|
||||
"version" : "0.10.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import BackgroundTasks
|
||||
import Flutter
|
||||
import SQLiteData
|
||||
import UIKit
|
||||
import network_info_plus
|
||||
import path_provider_foundation
|
||||
import permission_handler_apple
|
||||
import photo_manager
|
||||
import shared_preferences_foundation
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
private var backgroundCompletionHandlers: [String: () -> Void] = [:]
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
// Required for flutter_local_notification
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
@@ -36,7 +39,9 @@ import UIKit
|
||||
}
|
||||
|
||||
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
|
||||
SharedPreferencesPlugin.register(
|
||||
with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!
|
||||
)
|
||||
}
|
||||
|
||||
if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
|
||||
@@ -50,13 +55,50 @@ import UIKit
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
backgroundCompletionHandlers[identifier] = completionHandler
|
||||
}
|
||||
|
||||
func completionHandler(forSession identifier: String) -> (() -> Void)? {
|
||||
return backgroundCompletionHandlers.removeValue(forKey: identifier)
|
||||
}
|
||||
|
||||
public static func registerPlugins(with engine: FlutterEngine) {
|
||||
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||
|
||||
let statusListener = StatusEventListener()
|
||||
StreamStatusStreamHandler.register(with: engine.binaryMessenger, streamHandler: statusListener)
|
||||
let progressListener = ProgressEventListener()
|
||||
StreamProgressStreamHandler.register(with: engine.binaryMessenger, streamHandler: progressListener)
|
||||
|
||||
let dbUrl = try! FileManager.default.url(
|
||||
for: .documentDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
).appendingPathComponent("immich.sqlite")
|
||||
let db = try! DatabasePool(path: dbUrl.path)
|
||||
let storeRepository = StoreRepository(db: db)
|
||||
let taskRepository = TaskRepository(db: db)
|
||||
|
||||
UploadApiSetup.setUp(
|
||||
binaryMessenger: engine.binaryMessenger,
|
||||
api: UploadApiImpl(
|
||||
storeRepository: storeRepository,
|
||||
taskRepository: taskRepository,
|
||||
statusListener: statusListener,
|
||||
progressListener: progressListener
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||
(engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine()
|
||||
}
|
||||
|
||||
@@ -29,15 +29,15 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
public static func registerBackgroundWorkers() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: processingTaskID, using: nil) { task in
|
||||
if task is BGProcessingTask {
|
||||
handleBackgroundProcessing(task: task as! BGProcessingTask)
|
||||
if case let task as BGProcessingTask = task {
|
||||
handleBackgroundProcessing(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
|
||||
if task is BGAppRefreshTask {
|
||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
|
||||
if case let task as BGAppRefreshTask = task {
|
||||
handleBackgroundRefresh(task: task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,16 +350,12 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
|
||||
|
||||
// If we have required Wi-Fi, we can check the isExpensive property
|
||||
let requireWifi = defaults.value(forKey: "require_wifi") as? Bool ?? false
|
||||
if (requireWifi) {
|
||||
let wifiMonitor = NWPathMonitor(requiredInterfaceType: .wifi)
|
||||
let isExpensive = wifiMonitor.currentPath.isExpensive
|
||||
if (isExpensive) {
|
||||
// The network is expensive and we have required Wi-Fi
|
||||
// Therefore, we will simply complete the task without
|
||||
// running it
|
||||
task.setTaskCompleted(success: true)
|
||||
return
|
||||
}
|
||||
|
||||
// The network is expensive and we have required Wi-Fi
|
||||
// Therefore, we will simply complete the task without
|
||||
// running it
|
||||
if (requireWifi && NetworkMonitor.shared.isExpensive) {
|
||||
return task.setTaskCompleted(success: true)
|
||||
}
|
||||
|
||||
// Schedule the next sync task so we can run this again later
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
class ImmichPlugin: NSObject {
|
||||
var detached: Bool
|
||||
|
||||
|
||||
override init() {
|
||||
detached = false
|
||||
super.init()
|
||||
}
|
||||
|
||||
|
||||
func detachFromEngine() {
|
||||
self.detached = true
|
||||
}
|
||||
|
||||
|
||||
func completeWhenActive<T>(for completion: @escaping (T) -> Void, with value: T) {
|
||||
guard !self.detached else { return }
|
||||
completion(value)
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
func dPrint(_ item: Any) {
|
||||
#if DEBUG
|
||||
print(item)
|
||||
#endif
|
||||
}
|
||||
|
||||
203
mobile/ios/Runner/Repositories/StoreRepository.swift
Normal file
203
mobile/ios/Runner/Repositories/StoreRepository.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
import SQLiteData
|
||||
|
||||
protocol StoreProtocol {
|
||||
func get<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>) throws -> T?
|
||||
func get<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>) throws -> T?
|
||||
func set<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>, value: T) throws
|
||||
func set<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>, value: T) throws
|
||||
func invalidateCache()
|
||||
}
|
||||
|
||||
protocol StoreConvertible<StorageType> {
|
||||
associatedtype StorageType
|
||||
static var cacheKeyPath: ReferenceWritableKeyPath<StoreCache, [StoreKey: Self]> { get }
|
||||
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
|
||||
static func toValue(_ value: Self) throws(StoreError) -> StorageType
|
||||
}
|
||||
|
||||
final class StoreRepository: StoreProtocol {
|
||||
private let db: DatabasePool
|
||||
private static let cache = StoreCache()
|
||||
private static var lock = os_unfair_lock()
|
||||
|
||||
init(db: DatabasePool) {
|
||||
self.db = db
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>) throws -> T? {
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
defer { os_unfair_lock_unlock(&Self.lock) }
|
||||
let cached = Self.cache.get(key)
|
||||
if _fastPath(cached != nil) { return cached! }
|
||||
return try db.read { conn in
|
||||
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try query.fetchOne(conn) ?? nil {
|
||||
let converted = try T.fromValue(value)
|
||||
Self.cache.set(key, value: converted)
|
||||
return converted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>) throws -> T? {
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
defer { os_unfair_lock_unlock(&Self.lock) }
|
||||
let cached = Self.cache.get(key)
|
||||
if _fastPath(cached != nil) { return cached! }
|
||||
return try db.read { conn in
|
||||
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try query.fetchOne(conn) ?? nil {
|
||||
let converted = try T.fromValue(value)
|
||||
Self.cache.set(key, value: converted)
|
||||
return converted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>, value: T) throws {
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
defer { os_unfair_lock_unlock(&Self.lock) }
|
||||
let converted = try T.toValue(value)
|
||||
try db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: converted) }.execute(conn)
|
||||
}
|
||||
Self.cache.set(key, value: value)
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>, value: T) throws {
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
defer { os_unfair_lock_unlock(&Self.lock) }
|
||||
let converted = try T.toValue(value)
|
||||
try db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: converted, intValue: nil) }.execute(conn)
|
||||
}
|
||||
Self.cache.set(key, value: value)
|
||||
}
|
||||
|
||||
func invalidateCache() {
|
||||
Self.cache.reset()
|
||||
}
|
||||
}
|
||||
|
||||
enum StoreError: Error {
|
||||
case invalidJSON(String)
|
||||
case invalidURL(String)
|
||||
case encodingFailed
|
||||
case notFound
|
||||
}
|
||||
|
||||
extension StoreConvertible {
|
||||
fileprivate static func get(_ cache: StoreCache, key: StoreKey) -> Self? {
|
||||
return cache[keyPath: cacheKeyPath][key]
|
||||
}
|
||||
|
||||
fileprivate static func set(_ cache: StoreCache, key: StoreKey, value: Self?) {
|
||||
cache[keyPath: cacheKeyPath][key] = value
|
||||
}
|
||||
|
||||
fileprivate static func reset(_ cache: StoreCache) {
|
||||
cache.reset()
|
||||
}
|
||||
}
|
||||
|
||||
final class StoreCache {
|
||||
fileprivate var intCache: [StoreKey: Int] = [:]
|
||||
fileprivate var boolCache: [StoreKey: Bool] = [:]
|
||||
fileprivate var dateCache: [StoreKey: Date] = [:]
|
||||
fileprivate var stringCache: [StoreKey: String] = [:]
|
||||
fileprivate var urlCache: [StoreKey: URL] = [:]
|
||||
fileprivate var endpointArrayCache: [StoreKey: [Endpoint]] = [:]
|
||||
fileprivate var stringDictCache: [StoreKey: [String: String]] = [:]
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) -> T? {
|
||||
return T.get(self, key: key.rawValue)
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T?) {
|
||||
return T.set(self, key: key.rawValue, value: value)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
intCache.removeAll(keepingCapacity: true)
|
||||
boolCache.removeAll(keepingCapacity: true)
|
||||
dateCache.removeAll(keepingCapacity: true)
|
||||
stringCache.removeAll(keepingCapacity: true)
|
||||
urlCache.removeAll(keepingCapacity: true)
|
||||
endpointArrayCache.removeAll(keepingCapacity: true)
|
||||
stringDictCache.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension Int: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.intCache
|
||||
static func fromValue(_ value: Int) -> Int { value }
|
||||
static func toValue(_ value: Int) -> Int { value }
|
||||
}
|
||||
|
||||
extension Bool: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.boolCache
|
||||
static func fromValue(_ value: Int) -> Bool { value == 1 }
|
||||
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
|
||||
}
|
||||
|
||||
extension Date: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.dateCache
|
||||
static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
|
||||
static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
|
||||
}
|
||||
|
||||
extension String: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.stringCache
|
||||
static func fromValue(_ value: String) -> String { value }
|
||||
static func toValue(_ value: String) -> String { value }
|
||||
}
|
||||
|
||||
extension URL: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.urlCache
|
||||
static func fromValue(_ value: String) throws(StoreError) -> URL {
|
||||
guard let url = URL(string: value) else {
|
||||
throw StoreError.invalidURL(value)
|
||||
}
|
||||
return url
|
||||
}
|
||||
static func toValue(_ value: URL) -> String { value.absoluteString }
|
||||
}
|
||||
|
||||
extension StoreConvertible<String> where Self: Codable {
|
||||
static var jsonDecoder: JSONDecoder { JSONDecoder() }
|
||||
static var jsonEncoder: JSONEncoder { JSONEncoder() }
|
||||
|
||||
static func fromValue(_ value: String) throws(StoreError) -> Self {
|
||||
do {
|
||||
return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
|
||||
} catch {
|
||||
throw StoreError.invalidJSON(value)
|
||||
}
|
||||
}
|
||||
|
||||
static func toValue(_ value: Self) throws(StoreError) -> String {
|
||||
let encoded: Data
|
||||
do {
|
||||
encoded = try jsonEncoder.encode(value)
|
||||
} catch {
|
||||
throw StoreError.encodingFailed
|
||||
}
|
||||
|
||||
guard let string = String(data: encoded, encoding: .utf8) else {
|
||||
throw StoreError.encodingFailed
|
||||
}
|
||||
return string
|
||||
}
|
||||
}
|
||||
|
||||
extension Array: StoreConvertible where Element == Endpoint {
|
||||
static let cacheKeyPath = \StoreCache.endpointArrayCache
|
||||
typealias StorageType = String
|
||||
}
|
||||
|
||||
extension Dictionary: StoreConvertible where Key == String, Value == String {
|
||||
static let cacheKeyPath = \StoreCache.stringDictCache
|
||||
typealias StorageType = String
|
||||
}
|
||||
279
mobile/ios/Runner/Repositories/TaskRepository.swift
Normal file
279
mobile/ios/Runner/Repositories/TaskRepository.swift
Normal file
@@ -0,0 +1,279 @@
|
||||
import SQLiteData
|
||||
|
||||
protocol TaskProtocol {
|
||||
func getTaskIds(status: TaskStatus) async throws -> [Int64]
|
||||
func getBackupCandidates() async throws -> [LocalAssetCandidate]
|
||||
func getBackupCandidates(ids: [String]) async throws -> [LocalAssetCandidate]
|
||||
func getDownloadTasks() async throws -> [LocalAssetDownloadData]
|
||||
func getUploadTasks() async throws -> [LocalAssetUploadData]
|
||||
func markOrphansPending(ids: [Int64]) async throws
|
||||
func markDownloadQueued(taskId: Int64, isLivePhoto: Bool, filePath: URL) async throws
|
||||
func markUploadQueued(taskId: Int64) async throws
|
||||
func markDownloadComplete(taskId: Int64, localId: String, hash: String?) async throws -> TaskStatus
|
||||
func markUploadSuccess(taskId: Int64, livePhotoVideoId: String?) async throws
|
||||
func retryOrFail(taskId: Int64, code: UploadErrorCode, status: TaskStatus) async throws
|
||||
func enqueue(assets: [LocalAssetCandidate], imagePriority: Float, videoPriority: Float) async throws
|
||||
func enqueue(files: [String]) async throws
|
||||
func resolveError(code: UploadErrorCode) async throws
|
||||
func getFilename(taskId: Int64) async throws -> String?
|
||||
}
|
||||
|
||||
final class TaskRepository: TaskProtocol {
|
||||
private let db: DatabasePool
|
||||
|
||||
init(db: DatabasePool) {
|
||||
self.db = db
|
||||
}
|
||||
|
||||
func getTaskIds(status: TaskStatus) async throws -> [Int64] {
|
||||
return try await db.read { conn in
|
||||
try UploadTask.select(\.id).where { $0.status.eq(status) }.fetchAll(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func getBackupCandidates() async throws -> [LocalAssetCandidate] {
|
||||
return try await db.read { conn in
|
||||
return try LocalAsset.backupCandidates.fetchAll(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func getBackupCandidates(ids: [String]) async throws -> [LocalAssetCandidate] {
|
||||
return try await db.read { conn in
|
||||
return try LocalAsset.backupCandidates.where { $0.id.in(ids) }.fetchAll(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func getDownloadTasks() async throws -> [LocalAssetDownloadData] {
|
||||
return try await db.read({ conn in
|
||||
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
|
||||
.where { task, _ in task.canRetry && task.noFatalError && LocalAsset.withChecksum.exists() }
|
||||
.select { task, asset in
|
||||
LocalAssetDownloadData.Columns(
|
||||
checksum: asset.checksum,
|
||||
createdAt: asset.createdAt,
|
||||
filename: asset.name,
|
||||
livePhotoVideoId: task.livePhotoVideoId,
|
||||
localId: asset.id,
|
||||
taskId: task.id,
|
||||
updatedAt: asset.updatedAt
|
||||
)
|
||||
}
|
||||
.order { task, asset in (task.priority.desc(), task.createdAt) }
|
||||
.limit { _, _ in UploadTaskStat.availableDownloadSlots }
|
||||
.fetchAll(conn)
|
||||
})
|
||||
}
|
||||
|
||||
func getUploadTasks() async throws -> [LocalAssetUploadData] {
|
||||
return try await db.read({ conn in
|
||||
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
|
||||
.where { task, _ in task.canRetry && task.noFatalError && LocalAsset.withChecksum.exists() }
|
||||
.select { task, asset in
|
||||
LocalAssetUploadData.Columns(
|
||||
filename: asset.name,
|
||||
filePath: task.filePath.unwrapped,
|
||||
priority: task.priority,
|
||||
taskId: task.id,
|
||||
type: asset.type
|
||||
)
|
||||
}
|
||||
.order { task, asset in (task.priority.desc(), task.createdAt) }
|
||||
.limit { task, _ in UploadTaskStat.availableUploadSlots }
|
||||
.fetchAll(conn)
|
||||
})
|
||||
}
|
||||
|
||||
func markOrphansPending(ids: [Int64]) async throws {
|
||||
try await db.write { conn in
|
||||
try UploadTask.update {
|
||||
$0.filePath = nil
|
||||
$0.status = .downloadPending
|
||||
}
|
||||
.where { row in row.status.in([TaskStatus.downloadQueued, TaskStatus.uploadPending]) || row.id.in(ids) }
|
||||
.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func markDownloadQueued(taskId: Int64, isLivePhoto: Bool, filePath: URL) async throws {
|
||||
try await db.write { conn in
|
||||
try UploadTask.update {
|
||||
$0.status = .downloadQueued
|
||||
$0.isLivePhoto = isLivePhoto
|
||||
$0.filePath = filePath
|
||||
}
|
||||
.where { $0.id.eq(taskId) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func markUploadQueued(taskId: Int64) async throws {
|
||||
try await db.write { conn in
|
||||
try UploadTask.update { row in
|
||||
row.status = .uploadQueued
|
||||
row.filePath = nil
|
||||
}
|
||||
.where { $0.id.eq(taskId) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func markDownloadComplete(taskId: Int64, localId: String, hash: String?) async throws -> TaskStatus {
|
||||
return try await db.write { conn in
|
||||
if let hash {
|
||||
try LocalAsset.update { $0.checksum = hash }.where { $0.id.eq(localId) }.execute(conn)
|
||||
}
|
||||
let status =
|
||||
if let hash, try RemoteAsset.select(\.rowid).where({ $0.checksum.eq(hash) }).fetchOne(conn) != nil {
|
||||
TaskStatus.uploadSkipped
|
||||
} else {
|
||||
TaskStatus.uploadPending
|
||||
}
|
||||
try UploadTask.update { $0.status = status }.where { $0.id.eq(taskId) }.execute(conn)
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func markUploadSuccess(taskId: Int64, livePhotoVideoId: String?) async throws {
|
||||
try await db.write { conn in
|
||||
let task =
|
||||
try UploadTask
|
||||
.update { $0.status = .uploadComplete }
|
||||
.where { $0.id.eq(taskId) }
|
||||
.returning(\.self)
|
||||
.fetchOne(conn)
|
||||
guard let task, let localId = task.localId, let isLivePhoto = task.isLivePhoto, isLivePhoto,
|
||||
task.livePhotoVideoId == nil
|
||||
else { return }
|
||||
try UploadTask.insert {
|
||||
UploadTask.Draft(
|
||||
attempts: 0,
|
||||
createdAt: Date(),
|
||||
filePath: nil,
|
||||
isLivePhoto: true,
|
||||
lastError: nil,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
localId: localId,
|
||||
method: .multipart,
|
||||
priority: 0.7,
|
||||
retryAfter: nil,
|
||||
status: .downloadPending,
|
||||
)
|
||||
}.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func retryOrFail(taskId: Int64, code: UploadErrorCode, status: TaskStatus) async throws {
|
||||
try await db.write { conn in
|
||||
try UploadTask.update { row in
|
||||
let retryOffset =
|
||||
switch code {
|
||||
case .iCloudThrottled, .iCloudRateLimit, .notEnoughSpace: 3000
|
||||
default: 0
|
||||
}
|
||||
row.status = Case()
|
||||
.when(row.localId.is(nil) && row.attempts.lte(TaskConfig.maxRetries), then: TaskStatus.uploadPending)
|
||||
.when(row.attempts.lte(TaskConfig.maxRetries), then: TaskStatus.downloadPending)
|
||||
.else(status)
|
||||
row.attempts += 1
|
||||
row.lastError = code
|
||||
row.retryAfter = #sql("unixepoch('now') + (\(4 << row.attempts)) + \(retryOffset)")
|
||||
}
|
||||
.where { $0.id.eq(taskId) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func enqueue(assets: [LocalAssetCandidate], imagePriority: Float, videoPriority: Float) async throws {
|
||||
try await db.write { conn in
|
||||
var draft = draftStub
|
||||
for candidate in assets {
|
||||
draft.localId = candidate.id
|
||||
draft.priority = candidate.type == .image ? imagePriority : videoPriority
|
||||
try UploadTask.insert {
|
||||
draft
|
||||
} onConflict: {
|
||||
($0.localId, $0.livePhotoVideoId)
|
||||
}
|
||||
.execute(conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enqueue(files: [String]) async throws {
|
||||
try await db.write { conn in
|
||||
var draft = draftStub
|
||||
draft.priority = 1.0
|
||||
draft.status = .uploadPending
|
||||
for file in files {
|
||||
draft.filePath = URL(fileURLWithPath: file, isDirectory: false)
|
||||
try UploadTask.insert { draft }.execute(conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveError(code: UploadErrorCode) async throws {
|
||||
try await db.write { conn in
|
||||
try UploadTask.update { $0.lastError = nil }.where { $0.lastError.unwrapped.eq(code) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func getFilename(taskId: Int64) async throws -> String? {
|
||||
try await db.read { conn in
|
||||
try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }.select(\.1.name).fetchOne(conn)
|
||||
}
|
||||
}
|
||||
|
||||
private var draftStub: UploadTask.Draft {
|
||||
.init(
|
||||
attempts: 0,
|
||||
createdAt: Date(),
|
||||
filePath: nil,
|
||||
isLivePhoto: nil,
|
||||
lastError: nil,
|
||||
livePhotoVideoId: nil,
|
||||
localId: nil,
|
||||
method: .multipart,
|
||||
priority: 0.5,
|
||||
retryAfter: nil,
|
||||
status: .downloadPending,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension UploadTask.TableColumns {
|
||||
var noFatalError: some QueryExpression<Bool> { lastError.is(nil) || !lastError.unwrapped.in(UploadErrorCode.fatal) }
|
||||
var canRetry: some QueryExpression<Bool> {
|
||||
attempts.lte(TaskConfig.maxRetries) && (retryAfter.is(nil) || retryAfter.unwrapped <= Date().unixTime)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalAlbum {
|
||||
static let selected = Self.where { $0.backupSelection.eq(BackupSelection.selected) }
|
||||
static let excluded = Self.where { $0.backupSelection.eq(BackupSelection.excluded) }
|
||||
}
|
||||
|
||||
extension LocalAlbumAsset {
|
||||
static let selected = Self.where {
|
||||
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.selected.select(\.id))
|
||||
}
|
||||
static let excluded = Self.where {
|
||||
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.excluded.select(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
extension RemoteAsset {
|
||||
static let currentUser = Self.where { _ in
|
||||
ownerId.eq(Store.select(\.stringValue).where { $0.id.eq(StoreKey.currentUser.rawValue) }.unwrapped)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalAsset {
|
||||
static let withChecksum = Self.where { $0.checksum.isNot(nil) }
|
||||
static let shouldBackup = Self.where { _ in LocalAlbumAsset.selected.exists() && !LocalAlbumAsset.excluded.exists() }
|
||||
static let notBackedUp = Self.where { local in
|
||||
!RemoteAsset.currentUser.where { remote in local.checksum.eq(remote.checksum) }.exists()
|
||||
}
|
||||
static let backupCandidates = Self
|
||||
.shouldBackup
|
||||
.notBackedUp
|
||||
.where { local in !UploadTask.where { $0.localId.eq(local.id) }.exists() }
|
||||
.select { LocalAssetCandidate.Columns(id: $0.id, type: $0.type) }
|
||||
.limit { _ in UploadTaskStat.availableSlots }
|
||||
}
|
||||
@@ -9,8 +9,6 @@
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.immich.share</string>
|
||||
</array>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.immich.share</string>
|
||||
</array>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import SQLiteData
|
||||
|
||||
struct Endpoint: Codable {
|
||||
let url: URL
|
||||
let status: Status
|
||||
extension Notification.Name {
|
||||
static let networkDidConnect = Notification.Name("networkDidConnect")
|
||||
static let downloadTaskDidComplete = Notification.Name("downloadTaskDidComplete")
|
||||
static let uploadTaskDidComplete = Notification.Name("uploadTaskDidComplete")
|
||||
}
|
||||
|
||||
enum Status: String, Codable {
|
||||
case loading, valid, error, unknown
|
||||
}
|
||||
enum TaskConfig {
|
||||
static let maxActiveDownloads = 3
|
||||
static let maxPendingDownloads = 50
|
||||
static let maxPendingUploads = 50
|
||||
static let maxRetries = 10
|
||||
static let sessionId = "app.mertalev.immich.upload"
|
||||
static let downloadCheckIntervalNs: UInt64 = 30_000_000_000 // 30 seconds
|
||||
static let downloadTimeoutS = TimeInterval(60)
|
||||
static let progressThrottleInterval = TimeInterval(0.1)
|
||||
static let transferSpeedAlpha = 0.4
|
||||
static let originalsDir = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"originals",
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
|
||||
enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||
@@ -47,8 +60,6 @@ enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||
static let deviceId = Typed<String>(rawValue: ._deviceId)
|
||||
case _accessToken = 11
|
||||
static let accessToken = Typed<String>(rawValue: ._accessToken)
|
||||
case _serverEndpoint = 12
|
||||
static let serverEndpoint = Typed<String>(rawValue: ._serverEndpoint)
|
||||
case _sslClientCertData = 15
|
||||
static let sslClientCertData = Typed<String>(rawValue: ._sslClientCertData)
|
||||
case _sslClientPasswd = 16
|
||||
@@ -67,10 +78,12 @@ enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||
static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList)
|
||||
|
||||
// MARK: - URL
|
||||
case _localEndpoint = 134
|
||||
static let localEndpoint = Typed<URL>(rawValue: ._localEndpoint)
|
||||
case _serverUrl = 10
|
||||
static let serverUrl = Typed<URL>(rawValue: ._serverUrl)
|
||||
case _serverEndpoint = 12
|
||||
static let serverEndpoint = Typed<URL>(rawValue: ._serverEndpoint)
|
||||
case _localEndpoint = 134
|
||||
static let localEndpoint = Typed<URL>(rawValue: ._localEndpoint)
|
||||
|
||||
// MARK: - Date
|
||||
case _backupFailedSince = 5
|
||||
@@ -160,6 +173,17 @@ enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||
}
|
||||
}
|
||||
|
||||
enum UploadHeaders: String {
|
||||
case reprDigest = "Repr-Digest"
|
||||
case userToken = "X-Immich-User-Token"
|
||||
case assetData = "X-Immich-Asset-Data"
|
||||
}
|
||||
|
||||
enum TaskStatus: Int, QueryBindable {
|
||||
case downloadPending, downloadQueued, downloadFailed, uploadPending, uploadQueued, uploadFailed, uploadComplete,
|
||||
uploadSkipped
|
||||
}
|
||||
|
||||
enum BackupSelection: Int, QueryBindable {
|
||||
case selected, none, excluded
|
||||
}
|
||||
@@ -175,3 +199,89 @@ enum AlbumUserRole: Int, QueryBindable {
|
||||
enum MemoryType: Int, QueryBindable {
|
||||
case onThisDay
|
||||
}
|
||||
|
||||
enum AssetVisibility: Int, QueryBindable {
|
||||
case timeline, hidden, archive, locked
|
||||
}
|
||||
|
||||
enum SourceType: String, QueryBindable {
|
||||
case machineLearning = "machine-learning"
|
||||
case exif, manual
|
||||
}
|
||||
|
||||
enum UploadMethod: Int, QueryBindable {
|
||||
case multipart, resumable
|
||||
}
|
||||
|
||||
enum UploadError: Error {
|
||||
case fileCreationFailed
|
||||
case iCloudError(UploadErrorCode)
|
||||
case photosError(UploadErrorCode)
|
||||
case unknown
|
||||
|
||||
var code: UploadErrorCode {
|
||||
switch self {
|
||||
case .iCloudError(let code), .photosError(let code):
|
||||
return code
|
||||
case .unknown:
|
||||
return .unknown
|
||||
case .fileCreationFailed:
|
||||
return .writeFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum UploadErrorCode: Int, QueryBindable {
|
||||
case unknown
|
||||
case assetNotFound
|
||||
case fileNotFound
|
||||
case resourceNotFound
|
||||
case invalidResource
|
||||
case encodingFailed
|
||||
case writeFailed
|
||||
case notEnoughSpace
|
||||
case networkError
|
||||
case photosInternalError
|
||||
case photosUnknownError
|
||||
case interrupted
|
||||
case cancelled
|
||||
case downloadStalled
|
||||
case forceQuit
|
||||
case outOfResources
|
||||
case backgroundUpdatesDisabled
|
||||
case uploadTimeout
|
||||
case iCloudRateLimit
|
||||
case iCloudThrottled
|
||||
case invalidResponse
|
||||
case badRequest
|
||||
case internalServerError
|
||||
case unauthorized
|
||||
|
||||
static let fatal: [UploadErrorCode] = [.assetNotFound, .resourceNotFound, .invalidResource, .badRequest, .unauthorized]
|
||||
}
|
||||
|
||||
enum AssetType: Int, QueryBindable {
|
||||
case other, image, video, audio
|
||||
}
|
||||
|
||||
enum AssetMediaStatus: String, Codable {
|
||||
case created, replaced, duplicate
|
||||
}
|
||||
|
||||
struct Endpoint: Codable {
|
||||
let url: URL
|
||||
let status: Status
|
||||
|
||||
enum Status: String, Codable {
|
||||
case loading, valid, error, unknown
|
||||
}
|
||||
}
|
||||
|
||||
struct UploadSuccessResponse: Codable {
|
||||
let status: AssetMediaStatus
|
||||
let id: String
|
||||
}
|
||||
|
||||
struct UploadErrorResponse: Codable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import SQLiteData
|
||||
|
||||
enum StoreError: Error {
|
||||
case invalidJSON(String)
|
||||
case invalidURL(String)
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
protocol StoreConvertible {
|
||||
associatedtype StorageType
|
||||
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
|
||||
static func toValue(_ value: Self) throws(StoreError) -> StorageType
|
||||
}
|
||||
|
||||
extension Int: StoreConvertible {
|
||||
static func fromValue(_ value: Int) -> Int { value }
|
||||
static func toValue(_ value: Int) -> Int { value }
|
||||
}
|
||||
|
||||
extension Bool: StoreConvertible {
|
||||
static func fromValue(_ value: Int) -> Bool { value == 1 }
|
||||
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
|
||||
}
|
||||
|
||||
extension Date: StoreConvertible {
|
||||
static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
|
||||
static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
|
||||
}
|
||||
|
||||
extension String: StoreConvertible {
|
||||
static func fromValue(_ value: String) -> String { value }
|
||||
static func toValue(_ value: String) -> String { value }
|
||||
}
|
||||
|
||||
extension URL: StoreConvertible {
|
||||
static func fromValue(_ value: String) throws(StoreError) -> URL {
|
||||
guard let url = URL(string: value) else {
|
||||
throw StoreError.invalidURL(value)
|
||||
}
|
||||
return url
|
||||
}
|
||||
static func toValue(_ value: URL) -> String { value.absoluteString }
|
||||
}
|
||||
|
||||
extension StoreConvertible where Self: Codable, StorageType == String {
|
||||
static var jsonDecoder: JSONDecoder { JSONDecoder() }
|
||||
static var jsonEncoder: JSONEncoder { JSONEncoder() }
|
||||
|
||||
static func fromValue(_ value: String) throws(StoreError) -> Self {
|
||||
do {
|
||||
return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
|
||||
} catch {
|
||||
throw StoreError.invalidJSON(value)
|
||||
}
|
||||
}
|
||||
|
||||
static func toValue(_ value: Self) throws(StoreError) -> String {
|
||||
let encoded: Data
|
||||
do {
|
||||
encoded = try jsonEncoder.encode(value)
|
||||
} catch {
|
||||
throw StoreError.encodingFailed
|
||||
}
|
||||
|
||||
guard let string = String(data: encoded, encoding: .utf8) else {
|
||||
throw StoreError.encodingFailed
|
||||
}
|
||||
return string
|
||||
}
|
||||
}
|
||||
|
||||
extension Array: StoreConvertible where Element: Codable {
|
||||
typealias StorageType = String
|
||||
}
|
||||
|
||||
extension Dictionary: StoreConvertible where Key == String, Value: Codable {
|
||||
typealias StorageType = String
|
||||
}
|
||||
|
||||
class StoreRepository {
|
||||
private let db: DatabasePool
|
||||
|
||||
init(db: DatabasePool) {
|
||||
self.db = db
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == Int {
|
||||
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == String {
|
||||
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == Int {
|
||||
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == String {
|
||||
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == Int {
|
||||
let value = try T.toValue(value)
|
||||
try db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == String {
|
||||
let value = try T.toValue(value)
|
||||
try db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == Int {
|
||||
let value = try T.toValue(value)
|
||||
try await db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == String {
|
||||
let value = try T.toValue(value)
|
||||
try await db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +1,227 @@
|
||||
import GRDB
|
||||
import SQLiteData
|
||||
|
||||
extension QueryExpression where QueryValue: _OptionalProtocol {
|
||||
// asserts column result cannot be nil
|
||||
var unwrapped: SQLQueryExpression<QueryValue.Wrapped> {
|
||||
SQLQueryExpression(self.queryFragment, as: QueryValue.Wrapped.self)
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
var unixTime: Date.UnixTimeRepresentation {
|
||||
return Date.UnixTimeRepresentation(queryOutput: self)
|
||||
}
|
||||
}
|
||||
|
||||
@Table("asset_face_entity")
|
||||
struct AssetFace {
|
||||
struct AssetFace: Identifiable {
|
||||
let id: String
|
||||
let assetId: String
|
||||
let personId: String?
|
||||
@Column("asset_id")
|
||||
let assetId: RemoteAsset.ID
|
||||
@Column("person_id")
|
||||
let personId: Person.ID?
|
||||
@Column("image_width")
|
||||
let imageWidth: Int
|
||||
@Column("image_height")
|
||||
let imageHeight: Int
|
||||
@Column("bounding_box_x1")
|
||||
let boundingBoxX1: Int
|
||||
@Column("bounding_box_y1")
|
||||
let boundingBoxY1: Int
|
||||
@Column("bounding_box_x2")
|
||||
let boundingBoxX2: Int
|
||||
@Column("bounding_box_y2")
|
||||
let boundingBoxY2: Int
|
||||
let sourceType: String
|
||||
@Column("source_type")
|
||||
let sourceType: SourceType
|
||||
}
|
||||
|
||||
@Table("auth_user_entity")
|
||||
struct AuthUser {
|
||||
struct AuthUser: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let email: String
|
||||
@Column("is_admin")
|
||||
let isAdmin: Bool
|
||||
@Column("has_profile_image")
|
||||
let hasProfileImage: Bool
|
||||
@Column("profile_changed_at")
|
||||
let profileChangedAt: Date
|
||||
@Column("avatar_color")
|
||||
let avatarColor: AvatarColor
|
||||
@Column("quota_size_in_bytes")
|
||||
let quotaSizeInBytes: Int
|
||||
@Column("quota_usage_in_bytes")
|
||||
let quotaUsageInBytes: Int
|
||||
@Column("pin_code")
|
||||
let pinCode: String?
|
||||
}
|
||||
|
||||
@Table("local_album_entity")
|
||||
struct LocalAlbum {
|
||||
struct LocalAlbum: Identifiable {
|
||||
let id: String
|
||||
@Column("backup_selection")
|
||||
let backupSelection: BackupSelection
|
||||
let linkedRemoteAlbumId: String?
|
||||
@Column("linked_remote_album_id")
|
||||
let linkedRemoteAlbumId: RemoteAlbum.ID?
|
||||
@Column("marker")
|
||||
let marker_: Bool?
|
||||
let name: String
|
||||
@Column("is_ios_shared_album")
|
||||
let isIosSharedAlbum: Bool
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
@Table("local_album_asset_entity")
|
||||
struct LocalAlbumAsset {
|
||||
let id: ID
|
||||
@Column("marker")
|
||||
let marker_: String?
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("asset_id")
|
||||
let assetId: String
|
||||
@Column("album_id")
|
||||
let albumId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("local_asset_entity")
|
||||
struct LocalAsset {
|
||||
struct LocalAsset: Identifiable {
|
||||
let id: String
|
||||
let checksum: String?
|
||||
let createdAt: Date
|
||||
@Column("created_at")
|
||||
let createdAt: String
|
||||
@Column("duration_in_seconds")
|
||||
let durationInSeconds: Int?
|
||||
let height: Int?
|
||||
@Column("is_favorite")
|
||||
let isFavorite: Bool
|
||||
let name: String
|
||||
let orientation: String
|
||||
let type: Int
|
||||
let updatedAt: Date
|
||||
let type: AssetType
|
||||
@Column("updated_at")
|
||||
let updatedAt: String
|
||||
let width: Int?
|
||||
}
|
||||
|
||||
@Selection
|
||||
struct LocalAssetCandidate {
|
||||
let id: LocalAsset.ID
|
||||
let type: AssetType
|
||||
}
|
||||
|
||||
@Selection
|
||||
struct LocalAssetDownloadData {
|
||||
let checksum: String?
|
||||
let createdAt: String
|
||||
let filename: String
|
||||
let livePhotoVideoId: RemoteAsset.ID?
|
||||
let localId: LocalAsset.ID
|
||||
let taskId: UploadTask.ID
|
||||
let updatedAt: String
|
||||
}
|
||||
|
||||
@Selection
|
||||
struct LocalAssetUploadData {
|
||||
let filename: String
|
||||
let filePath: URL
|
||||
let priority: Float
|
||||
let taskId: UploadTask.ID
|
||||
let type: AssetType
|
||||
}
|
||||
|
||||
@Table("memory_asset_entity")
|
||||
struct MemoryAsset {
|
||||
let id: ID
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("asset_id")
|
||||
let assetId: String
|
||||
@Column("album_id")
|
||||
let albumId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("memory_entity")
|
||||
struct Memory {
|
||||
struct Memory: Identifiable {
|
||||
let id: String
|
||||
@Column("created_at")
|
||||
let createdAt: Date
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
@Column("deleted_at")
|
||||
let deletedAt: Date?
|
||||
let ownerId: String
|
||||
@Column("owner_id")
|
||||
let ownerId: User.ID
|
||||
let type: MemoryType
|
||||
let data: String
|
||||
@Column("is_saved")
|
||||
let isSaved: Bool
|
||||
@Column("memory_at")
|
||||
let memoryAt: Date
|
||||
@Column("seen_at")
|
||||
let seenAt: Date?
|
||||
@Column("show_at")
|
||||
let showAt: Date?
|
||||
@Column("hide_at")
|
||||
let hideAt: Date?
|
||||
}
|
||||
|
||||
@Table("partner_entity")
|
||||
struct Partner {
|
||||
let id: ID
|
||||
@Column("in_timeline")
|
||||
let inTimeline: Bool
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("shared_by_id")
|
||||
let sharedById: String
|
||||
@Column("shared_with_id")
|
||||
let sharedWithId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("person_entity")
|
||||
struct Person {
|
||||
struct Person: Identifiable {
|
||||
let id: String
|
||||
@Column("created_at")
|
||||
let createdAt: Date
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
@Column("owner_id")
|
||||
let ownerId: String
|
||||
let name: String
|
||||
let faceAssetId: String?
|
||||
@Column("face_asset_id")
|
||||
let faceAssetId: AssetFace.ID?
|
||||
@Column("is_favorite")
|
||||
let isFavorite: Bool
|
||||
@Column("is_hidden")
|
||||
let isHidden: Bool
|
||||
let color: String?
|
||||
@Column("birth_date")
|
||||
let birthDate: Date?
|
||||
}
|
||||
|
||||
@Table("remote_album_entity")
|
||||
struct RemoteAlbum {
|
||||
struct RemoteAlbum: Identifiable {
|
||||
let id: String
|
||||
@Column("created_at")
|
||||
let createdAt: Date
|
||||
let description: String?
|
||||
@Column("is_activity_enabled")
|
||||
let isActivityEnabled: Bool
|
||||
let name: String
|
||||
let order: Int
|
||||
@Column("owner_id")
|
||||
let ownerId: String
|
||||
let thumbnailAssetId: String?
|
||||
@Column("thumbnail_asset_id")
|
||||
let thumbnailAssetId: RemoteAsset.ID?
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
@@ -139,7 +231,9 @@ struct RemoteAlbumAsset {
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("asset_id")
|
||||
let assetId: String
|
||||
@Column("album_id")
|
||||
let albumId: String
|
||||
}
|
||||
}
|
||||
@@ -151,40 +245,55 @@ struct RemoteAlbumUser {
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("album_id")
|
||||
let albumId: String
|
||||
@Column("user_id")
|
||||
let userId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("remote_asset_entity")
|
||||
struct RemoteAsset {
|
||||
struct RemoteAsset: Identifiable {
|
||||
let id: String
|
||||
let checksum: String?
|
||||
let checksum: String
|
||||
@Column("is_favorite")
|
||||
let isFavorite: Bool
|
||||
@Column("deleted_at")
|
||||
let deletedAt: Date?
|
||||
let isFavorite: Int
|
||||
let libraryId: String?
|
||||
let livePhotoVideoId: String?
|
||||
@Column("owner_id")
|
||||
let ownerId: User.ID
|
||||
@Column("local_date_time")
|
||||
let localDateTime: Date?
|
||||
let orientation: String
|
||||
let ownerId: String
|
||||
let stackId: String?
|
||||
let visibility: Int
|
||||
@Column("thumb_hash")
|
||||
let thumbHash: String?
|
||||
@Column("library_id")
|
||||
let libraryId: String?
|
||||
@Column("live_photo_video_id")
|
||||
let livePhotoVideoId: String?
|
||||
@Column("stack_id")
|
||||
let stackId: Stack.ID?
|
||||
let visibility: AssetVisibility
|
||||
}
|
||||
|
||||
@Table("remote_exif_entity")
|
||||
struct RemoteExif {
|
||||
@Column(primaryKey: true)
|
||||
let assetId: String
|
||||
@Column("asset_id", primaryKey: true)
|
||||
let assetId: RemoteAsset.ID
|
||||
let city: String?
|
||||
let state: String?
|
||||
let country: String?
|
||||
@Column("date_time_original")
|
||||
let dateTimeOriginal: Date?
|
||||
let description: String?
|
||||
let height: Int?
|
||||
let width: Int?
|
||||
@Column("exposure_time")
|
||||
let exposureTime: String?
|
||||
@Column("f_number")
|
||||
let fNumber: Double?
|
||||
@Column("file_size")
|
||||
let fileSize: Int?
|
||||
@Column("focal_length")
|
||||
let focalLength: Double?
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
@@ -193,34 +302,101 @@ struct RemoteExif {
|
||||
let model: String?
|
||||
let lens: String?
|
||||
let orientation: String?
|
||||
@Column("time_zone")
|
||||
let timeZone: String?
|
||||
let rating: Int?
|
||||
@Column("projection_type")
|
||||
let projectionType: String?
|
||||
}
|
||||
|
||||
@Table("stack_entity")
|
||||
struct Stack {
|
||||
struct Stack: Identifiable {
|
||||
let id: String
|
||||
@Column("created_at")
|
||||
let createdAt: Date
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
let ownerId: String
|
||||
@Column("owner_id")
|
||||
let ownerId: User.ID
|
||||
@Column("primary_asset_id")
|
||||
let primaryAssetId: String
|
||||
}
|
||||
|
||||
@Table("store_entity")
|
||||
struct Store {
|
||||
struct Store: Identifiable {
|
||||
let id: StoreKey
|
||||
@Column("string_value")
|
||||
let stringValue: String?
|
||||
@Column("int_value")
|
||||
let intValue: Int?
|
||||
}
|
||||
|
||||
@Table("upload_tasks")
|
||||
struct UploadTask: Identifiable {
|
||||
let id: Int64
|
||||
let attempts: Int
|
||||
@Column("created_at", as: Date.UnixTimeRepresentation.self)
|
||||
let createdAt: Date
|
||||
@Column("file_path")
|
||||
var filePath: URL?
|
||||
@Column("is_live_photo")
|
||||
let isLivePhoto: Bool?
|
||||
@Column("last_error")
|
||||
let lastError: UploadErrorCode?
|
||||
@Column("live_photo_video_id")
|
||||
let livePhotoVideoId: RemoteAsset.ID?
|
||||
@Column("local_id")
|
||||
var localId: LocalAsset.ID?
|
||||
let method: UploadMethod
|
||||
var priority: Float
|
||||
@Column("retry_after", as: Date?.UnixTimeRepresentation.self)
|
||||
let retryAfter: Date?
|
||||
var status: TaskStatus
|
||||
}
|
||||
|
||||
@Table("upload_task_stats")
|
||||
struct UploadTaskStat {
|
||||
@Column("pending_downloads")
|
||||
let pendingDownloads: Int
|
||||
@Column("pending_uploads")
|
||||
let pendingUploads: Int
|
||||
@Column("queued_downloads")
|
||||
let queuedDownloads: Int
|
||||
@Column("queued_uploads")
|
||||
let queuedUploads: Int
|
||||
@Column("failed_downloads")
|
||||
let failedDownloads: Int
|
||||
@Column("failed_uploads")
|
||||
let failedUploads: Int
|
||||
@Column("completed_uploads")
|
||||
let completedUploads: Int
|
||||
@Column("skipped_uploads")
|
||||
let skippedUploads: Int
|
||||
|
||||
static let availableDownloadSlots = Self.select {
|
||||
TaskConfig.maxPendingDownloads - ($0.pendingDownloads + $0.queuedDownloads)
|
||||
}
|
||||
|
||||
static let availableUploadSlots = Self.select {
|
||||
TaskConfig.maxPendingUploads - ($0.pendingUploads + $0.queuedUploads)
|
||||
}
|
||||
|
||||
static let availableSlots = Self.select {
|
||||
TaskConfig.maxPendingUploads + TaskConfig.maxPendingDownloads
|
||||
- ($0.pendingDownloads + $0.queuedDownloads + $0.pendingUploads + $0.queuedUploads)
|
||||
}
|
||||
}
|
||||
|
||||
@Table("user_entity")
|
||||
struct User {
|
||||
struct User: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let email: String
|
||||
@Column("has_profile_image")
|
||||
let hasProfileImage: Bool
|
||||
@Column("profile_changed_at")
|
||||
let profileChangedAt: Date
|
||||
@Column("avatar_color")
|
||||
let avatarColor: AvatarColor
|
||||
}
|
||||
|
||||
@@ -231,6 +407,7 @@ struct UserMetadata {
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("user_id")
|
||||
let userId: String
|
||||
let key: Date
|
||||
}
|
||||
|
||||
@@ -140,9 +140,6 @@ struct PlatformAsset: Hashable {
|
||||
var durationInSeconds: Int64
|
||||
var orientation: Int64
|
||||
var isFavorite: Bool
|
||||
var adjustmentTime: Int64? = nil
|
||||
var latitude: Double? = nil
|
||||
var longitude: Double? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
@@ -157,9 +154,6 @@ struct PlatformAsset: Hashable {
|
||||
let durationInSeconds = pigeonVar_list[7] as! Int64
|
||||
let orientation = pigeonVar_list[8] as! Int64
|
||||
let isFavorite = pigeonVar_list[9] as! Bool
|
||||
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
|
||||
let latitude: Double? = nilOrValue(pigeonVar_list[11])
|
||||
let longitude: Double? = nilOrValue(pigeonVar_list[12])
|
||||
|
||||
return PlatformAsset(
|
||||
id: id,
|
||||
@@ -171,10 +165,7 @@ struct PlatformAsset: Hashable {
|
||||
height: height,
|
||||
durationInSeconds: durationInSeconds,
|
||||
orientation: orientation,
|
||||
isFavorite: isFavorite,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
isFavorite: isFavorite
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
@@ -189,9 +180,6 @@ struct PlatformAsset: Hashable {
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
]
|
||||
}
|
||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||
|
||||
@@ -12,10 +12,7 @@ extension PHAsset {
|
||||
height: Int64(pixelHeight),
|
||||
durationInSeconds: Int64(duration),
|
||||
orientation: 0,
|
||||
isFavorite: isFavorite,
|
||||
adjustmentTime: adjustmentTimestamp,
|
||||
latitude: location?.coordinate.latitude,
|
||||
longitude: location?.coordinate.longitude
|
||||
isFavorite: isFavorite
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,13 +23,6 @@ extension PHAsset {
|
||||
var filename: String? {
|
||||
return value(forKey: "filename") as? String
|
||||
}
|
||||
|
||||
var adjustmentTimestamp: Int64? {
|
||||
if let date = value(forKey: "adjustmentTimestamp") as? Date {
|
||||
return Int64(date.timeIntervalSince1970)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
|
||||
var originalFilename: String? {
|
||||
@@ -62,6 +52,23 @@ extension PHAsset {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLivePhotoResource() -> PHAssetResource? {
|
||||
let resources = PHAssetResource.assetResources(for: self)
|
||||
|
||||
var livePhotoResource: PHAssetResource?
|
||||
for resource in resources {
|
||||
if resource.type == .fullSizePairedVideo {
|
||||
return resource
|
||||
}
|
||||
|
||||
if resource.type == .pairedVideo {
|
||||
livePhotoResource = resource
|
||||
}
|
||||
}
|
||||
|
||||
return livePhotoResource
|
||||
}
|
||||
|
||||
private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
|
||||
switch mediaType {
|
||||
|
||||
62
mobile/ios/Runner/Upload/AssetData.swift
Normal file
62
mobile/ios/Runner/Upload/AssetData.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import StructuredFieldValues
|
||||
|
||||
struct AssetData: StructuredFieldValue {
|
||||
static let structuredFieldType: StructuredFieldType = .dictionary
|
||||
|
||||
let deviceAssetId: String
|
||||
let deviceId: String
|
||||
let fileCreatedAt: String
|
||||
let fileModifiedAt: String
|
||||
let fileName: String
|
||||
let isFavorite: Bool
|
||||
let livePhotoVideoId: String?
|
||||
|
||||
static let boundary = "Boundary-\(UUID().uuidString)"
|
||||
static let deviceAssetIdField = "--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceAssetId\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let deviceIdField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceId\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let fileCreatedAtField =
|
||||
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileCreatedAt\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let fileModifiedAtField =
|
||||
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileModifiedAt\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let isFavoriteField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"isFavorite\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let livePhotoVideoIdField =
|
||||
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"livePhotoVideoId\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let trueData = "true".data(using: .utf8)!
|
||||
static let falseData = "false".data(using: .utf8)!
|
||||
static let footer = "\r\n--\(boundary)--\r\n".data(using: .utf8)!
|
||||
static let contentType = "multipart/form-data; boundary=\(boundary)"
|
||||
|
||||
func multipart() -> (Data, Data) {
|
||||
var header = Data()
|
||||
header.append(Self.deviceAssetIdField)
|
||||
header.append(deviceAssetId.data(using: .utf8)!)
|
||||
|
||||
header.append(Self.deviceIdField)
|
||||
header.append(deviceId.data(using: .utf8)!)
|
||||
|
||||
header.append(Self.fileCreatedAtField)
|
||||
header.append(fileCreatedAt.data(using: .utf8)!)
|
||||
|
||||
header.append(Self.fileModifiedAtField)
|
||||
header.append(fileModifiedAt.data(using: .utf8)!)
|
||||
|
||||
header.append(Self.isFavoriteField)
|
||||
header.append(isFavorite ? Self.trueData : Self.falseData)
|
||||
|
||||
if let livePhotoVideoId {
|
||||
header.append(Self.livePhotoVideoIdField)
|
||||
header.append(livePhotoVideoId.data(using: .utf8)!)
|
||||
}
|
||||
header.append(
|
||||
"\r\n--\(Self.boundary)\r\nContent-Disposition: form-data; name=\"assetData\"; filename=\"\(fileName)\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
)
|
||||
return (header, Self.footer)
|
||||
}
|
||||
}
|
||||
214
mobile/ios/Runner/Upload/Delegate.swift
Normal file
214
mobile/ios/Runner/Upload/Delegate.swift
Normal file
@@ -0,0 +1,214 @@
|
||||
import SQLiteData
|
||||
|
||||
private let stateLock = NSLock()
|
||||
private var transferStates: [Int64: NetworkTransferState] = [:]
|
||||
private var responseData: [Int64: Data] = [:]
|
||||
private let jsonDecoder = JSONDecoder()
|
||||
|
||||
private class NetworkTransferState {
|
||||
var lastUpdateTime: Date
|
||||
var totalBytesTransferred: Int64
|
||||
var currentSpeed: Double?
|
||||
|
||||
init(lastUpdateTime: Date, totalBytesTransferred: Int64, currentSpeed: Double?) {
|
||||
self.lastUpdateTime = lastUpdateTime
|
||||
self.totalBytesTransferred = totalBytesTransferred
|
||||
self.currentSpeed = currentSpeed
|
||||
}
|
||||
}
|
||||
|
||||
final class UploadApiDelegate<
|
||||
TaskRepo: TaskProtocol,
|
||||
StatusListener: TaskStatusListener,
|
||||
ProgressListener: TaskProgressListener
|
||||
>: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate {
|
||||
private let taskRepository: TaskRepo
|
||||
private let statusListener: StatusListener
|
||||
private let progressListener: ProgressListener
|
||||
|
||||
init(taskRepository: TaskRepo, statusListener: StatusListener, progressListener: ProgressListener) {
|
||||
self.taskRepository = taskRepository
|
||||
self.statusListener = statusListener
|
||||
self.progressListener = progressListener
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
stateLock.withLock {
|
||||
transferStates.removeAll()
|
||||
responseData.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
guard let taskIdStr = dataTask.taskDescription,
|
||||
let taskId = Int64(taskIdStr)
|
||||
else { return }
|
||||
|
||||
stateLock.withLock {
|
||||
if var response = responseData[taskId] {
|
||||
response.append(data)
|
||||
} else {
|
||||
responseData[taskId] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
Task {
|
||||
defer {
|
||||
NotificationCenter.default.post(name: .uploadTaskDidComplete, object: nil)
|
||||
}
|
||||
|
||||
guard let taskDescriptionId = task.taskDescription,
|
||||
let taskId = Int64(taskDescriptionId)
|
||||
else {
|
||||
return dPrint("Unexpected: task without session ID completed")
|
||||
}
|
||||
|
||||
defer {
|
||||
stateLock.withLock { let _ = transferStates.removeValue(forKey: taskId) }
|
||||
}
|
||||
|
||||
if let body = stateLock.withLock({ responseData.removeValue(forKey: taskId) }),
|
||||
let response = task.response as? HTTPURLResponse
|
||||
{
|
||||
switch response.statusCode {
|
||||
case 200, 201:
|
||||
do {
|
||||
let response = try jsonDecoder.decode(UploadSuccessResponse.self, from: body)
|
||||
return await handleSuccess(taskId: taskId, response: response)
|
||||
} catch {
|
||||
return await handleFailure(taskId: taskId, code: .invalidResponse)
|
||||
}
|
||||
case 401: return await handleFailure(taskId: taskId, code: .unauthorized)
|
||||
case 400..<500:
|
||||
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
|
||||
return await handleFailure(taskId: taskId, code: .badRequest)
|
||||
case 500..<600:
|
||||
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
|
||||
return await handleFailure(taskId: taskId, code: .internalServerError)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let urlError = error as? URLError else {
|
||||
return await handleFailure(taskId: taskId)
|
||||
}
|
||||
|
||||
if #available(iOS 17, *), let resumeData = urlError.uploadTaskResumeData {
|
||||
return await handleFailure(taskDescriptionId: taskDescriptionId, session: session, resumeData: resumeData)
|
||||
}
|
||||
|
||||
let code: UploadErrorCode =
|
||||
switch urlError.backgroundTaskCancelledReason {
|
||||
case .backgroundUpdatesDisabled: .backgroundUpdatesDisabled
|
||||
case .insufficientSystemResources: .outOfResources
|
||||
case .userForceQuitApplication: .forceQuit
|
||||
default:
|
||||
switch urlError.code {
|
||||
case .networkConnectionLost, .notConnectedToInternet: .networkError
|
||||
case .timedOut: .uploadTimeout
|
||||
case .resourceUnavailable, .fileDoesNotExist: .fileNotFound
|
||||
default: .unknown
|
||||
}
|
||||
}
|
||||
await handleFailure(taskId: taskId, code: code)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didSendBodyData bytesSent: Int64,
|
||||
totalBytesSent: Int64,
|
||||
totalBytesExpectedToSend: Int64
|
||||
) {
|
||||
guard let sessionTaskId = task.taskDescription, let taskId = Int64(sessionTaskId) else { return }
|
||||
let currentTime = Date()
|
||||
let state = stateLock.withLock {
|
||||
if let existing = transferStates[taskId] {
|
||||
return existing
|
||||
}
|
||||
let new = NetworkTransferState(
|
||||
lastUpdateTime: currentTime,
|
||||
totalBytesTransferred: totalBytesSent,
|
||||
currentSpeed: nil
|
||||
)
|
||||
transferStates[taskId] = new
|
||||
return new
|
||||
}
|
||||
|
||||
let timeDelta = currentTime.timeIntervalSince(state.lastUpdateTime)
|
||||
guard timeDelta > 0 else { return }
|
||||
|
||||
let bytesDelta = totalBytesSent - state.totalBytesTransferred
|
||||
let instantSpeed = Double(bytesDelta) / timeDelta
|
||||
let currentSpeed =
|
||||
if let previousSpeed = state.currentSpeed {
|
||||
TaskConfig.transferSpeedAlpha * instantSpeed + (1 - TaskConfig.transferSpeedAlpha) * previousSpeed
|
||||
} else {
|
||||
instantSpeed
|
||||
}
|
||||
state.currentSpeed = currentSpeed
|
||||
state.lastUpdateTime = currentTime
|
||||
state.totalBytesTransferred = totalBytesSent
|
||||
self.progressListener.onTaskProgress(
|
||||
UploadApiTaskProgress(
|
||||
id: sessionTaskId,
|
||||
progress: Double(totalBytesSent) / Double(totalBytesExpectedToSend),
|
||||
speed: currentSpeed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
dPrint("All background events delivered for session: \(session.configuration.identifier ?? "unknown")")
|
||||
DispatchQueue.main.async {
|
||||
if let identifier = session.configuration.identifier,
|
||||
let appDelegate = UIApplication.shared.delegate as? AppDelegate,
|
||||
let completionHandler = appDelegate.completionHandler(forSession: identifier)
|
||||
{
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSuccess(taskId: Int64, response: UploadSuccessResponse) async {
|
||||
dPrint("Upload succeeded for task \(taskId), server ID: \(response.id)")
|
||||
do {
|
||||
try await taskRepository.markUploadSuccess(taskId: taskId, livePhotoVideoId: response.id)
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(
|
||||
id: String(taskId),
|
||||
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
|
||||
status: .uploadComplete
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
dPrint(
|
||||
"Failed to update upload success status for session task \(taskId): \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFailure(taskId: Int64, code: UploadErrorCode = .unknown) async {
|
||||
dPrint("Upload failed for task \(taskId) with code \(code)")
|
||||
try? await taskRepository.retryOrFail(taskId: taskId, code: code, status: .uploadFailed)
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(
|
||||
id: String(taskId),
|
||||
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
|
||||
status: .uploadFailed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
private func handleFailure(taskDescriptionId: String, session: URLSession, resumeData: Data) async {
|
||||
dPrint("Resuming upload for task \(taskDescriptionId)")
|
||||
let resumeTask = session.uploadTask(withResumeData: resumeData)
|
||||
resumeTask.taskDescription = taskDescriptionId
|
||||
resumeTask.resume()
|
||||
}
|
||||
}
|
||||
305
mobile/ios/Runner/Upload/DownloadQueue.swift
Normal file
305
mobile/ios/Runner/Upload/DownloadQueue.swift
Normal file
@@ -0,0 +1,305 @@
|
||||
import CryptoKit
|
||||
import Photos
|
||||
|
||||
private var queueProcessingTask: Task<Void, Never>?
|
||||
private var queueProcessingLock = NSLock()
|
||||
private let resourceManager = PHAssetResourceManager.default()
|
||||
|
||||
private final class RequestRef {
|
||||
var id: PHAssetResourceDataRequestID?
|
||||
var lastProgressTime = Date()
|
||||
var didStall = false
|
||||
}
|
||||
|
||||
final class DownloadQueue<
|
||||
StoreRepo: StoreProtocol,
|
||||
TaskRepo: TaskProtocol,
|
||||
StatusListener: TaskStatusListener,
|
||||
ProgressListener: TaskProgressListener
|
||||
> {
|
||||
private let storeRepository: StoreRepo
|
||||
private let taskRepository: TaskRepo
|
||||
private let statusListener: StatusListener
|
||||
private let progressListener: ProgressListener
|
||||
private var uploadObserver: NSObjectProtocol?
|
||||
private var networkObserver: NSObjectProtocol?
|
||||
|
||||
init(
|
||||
storeRepository: StoreRepo,
|
||||
taskRepository: TaskRepo,
|
||||
statusListener: StatusListener,
|
||||
progressListener: ProgressListener
|
||||
) {
|
||||
self.storeRepository = storeRepository
|
||||
self.taskRepository = taskRepository
|
||||
self.statusListener = statusListener
|
||||
self.progressListener = progressListener
|
||||
uploadObserver = NotificationCenter.default.addObserver(forName: .uploadTaskDidComplete, object: nil, queue: nil) {
|
||||
[weak self] _ in
|
||||
self?.startQueueProcessing()
|
||||
}
|
||||
networkObserver = NotificationCenter.default.addObserver(forName: .networkDidConnect, object: nil, queue: nil) {
|
||||
[weak self] _ in
|
||||
dPrint("Network connected")
|
||||
self?.startQueueProcessing()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
uploadObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||
networkObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||
}
|
||||
|
||||
func enqueueAssets(localIds: [String]) async throws {
|
||||
guard !localIds.isEmpty else { return dPrint("No assets to enqueue") }
|
||||
|
||||
defer { startQueueProcessing() }
|
||||
let candidates = try await taskRepository.getBackupCandidates(ids: localIds)
|
||||
|
||||
guard !candidates.isEmpty else { return dPrint("No candidates to enqueue") }
|
||||
|
||||
try await taskRepository.enqueue(assets: candidates, imagePriority: 0.9, videoPriority: 0.8)
|
||||
dPrint("Enqueued \(candidates.count) assets for upload")
|
||||
}
|
||||
|
||||
func startQueueProcessing() {
|
||||
dPrint("Starting download queue processing")
|
||||
queueProcessingLock.withLock {
|
||||
guard queueProcessingTask == nil else { return }
|
||||
queueProcessingTask = Task {
|
||||
await startDownloads()
|
||||
queueProcessingLock.withLock { queueProcessingTask = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startDownloads() async {
|
||||
dPrint("Processing download queue")
|
||||
|
||||
guard await UIApplication.shared.applicationState != .background else {
|
||||
return dPrint("Not processing downloads in background") // TODO: run in processing tasks
|
||||
}
|
||||
|
||||
guard NetworkMonitor.shared.isConnected,
|
||||
let backupEnabled = try? storeRepository.get(StoreKey.enableBackup), backupEnabled,
|
||||
let deviceId = try? storeRepository.get(StoreKey.deviceId)
|
||||
else { return dPrint("Download queue paused: missing preconditions") }
|
||||
|
||||
do {
|
||||
let tasks = try await taskRepository.getDownloadTasks()
|
||||
if tasks.isEmpty { return dPrint("No download tasks to process") }
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
var iterator = tasks.makeIterator()
|
||||
for _ in 0..<min(TaskConfig.maxActiveDownloads, tasks.count) {
|
||||
if let task = iterator.next() {
|
||||
group.addTask { await self.downloadAndQueue(task, deviceId: deviceId) }
|
||||
}
|
||||
}
|
||||
|
||||
while try await group.next() != nil {
|
||||
if let task = iterator.next() {
|
||||
group.addTask { await self.downloadAndQueue(task, deviceId: deviceId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
dPrint("Download queue error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAndQueue(_ task: LocalAssetDownloadData, deviceId: String) async {
|
||||
defer { startQueueProcessing() }
|
||||
dPrint("Starting download for task \(task.taskId)")
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [task.localId], options: nil).firstObject
|
||||
else {
|
||||
dPrint("Asset not found")
|
||||
return await handleFailure(task: task, code: .assetNotFound)
|
||||
}
|
||||
|
||||
let isLivePhoto = asset.mediaSubtypes.contains(.photoLive)
|
||||
let isMotion = isLivePhoto && task.livePhotoVideoId != nil
|
||||
guard let resource = isMotion ? asset.getLivePhotoResource() : asset.getResource() else {
|
||||
dPrint("Resource not found")
|
||||
return await handleFailure(task: task, code: .resourceNotFound)
|
||||
}
|
||||
|
||||
let fileDir = TaskConfig.originalsDir
|
||||
let fileName = "\(resource.assetLocalIdentifier.replacingOccurrences(of: "/", with: "_"))_\(resource.type.rawValue)"
|
||||
let filePath = fileDir.appendingPathComponent(fileName)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileDir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
} catch {
|
||||
dPrint("Failed to create directory for download task \(task.taskId): \(error)")
|
||||
return await handleFailure(task: task, code: .writeFailed, filePath: filePath)
|
||||
}
|
||||
|
||||
do {
|
||||
try await taskRepository.markDownloadQueued(taskId: task.taskId, isLivePhoto: isLivePhoto, filePath: filePath)
|
||||
} catch {
|
||||
return dPrint("Failed to set file path for download task \(task.taskId): \(error)")
|
||||
}
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(id: String(task.taskId), filename: filePath.path, status: .downloadQueued)
|
||||
)
|
||||
|
||||
do {
|
||||
let hash = try await download(task: task, asset: asset, resource: resource, to: filePath, deviceId: deviceId)
|
||||
let status = try await taskRepository.markDownloadComplete(taskId: task.taskId, localId: task.localId, hash: hash)
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(
|
||||
id: String(task.taskId),
|
||||
filename: task.filename,
|
||||
status: UploadApiStatus(rawValue: status.rawValue)!
|
||||
)
|
||||
)
|
||||
NotificationCenter.default.post(name: .downloadTaskDidComplete, object: nil)
|
||||
} catch let error as UploadError {
|
||||
dPrint("Download failed for task \(task.taskId): \(error)")
|
||||
await handleFailure(task: task, code: error.code, filePath: filePath)
|
||||
} catch {
|
||||
dPrint("Download failed for task \(task.taskId): \(error)")
|
||||
await handleFailure(task: task, code: .unknown, filePath: filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func download(
|
||||
task: LocalAssetDownloadData,
|
||||
asset: PHAsset,
|
||||
resource: PHAssetResource,
|
||||
to filePath: URL,
|
||||
deviceId: String
|
||||
) async throws
|
||||
-> String?
|
||||
{
|
||||
dPrint("Downloading asset resource \(resource.assetLocalIdentifier) of type \(resource.type.rawValue)")
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = true
|
||||
let (header, footer) = AssetData(
|
||||
deviceAssetId: task.localId,
|
||||
deviceId: deviceId,
|
||||
fileCreatedAt: task.createdAt,
|
||||
fileModifiedAt: task.updatedAt,
|
||||
fileName: resource.originalFilename,
|
||||
isFavorite: asset.isFavorite,
|
||||
livePhotoVideoId: nil
|
||||
).multipart()
|
||||
|
||||
guard let fileHandle = try? FileHandle.createOrOverwrite(atPath: filePath.path) else {
|
||||
dPrint("Failed to open file handle for download task \(task.taskId), path: \(filePath.path)")
|
||||
throw UploadError.fileCreationFailed
|
||||
}
|
||||
try fileHandle.write(contentsOf: header)
|
||||
|
||||
var lastProgressTime = Date()
|
||||
let taskIdStr = String(task.taskId)
|
||||
options.progressHandler = { progress in
|
||||
lastProgressTime = Date()
|
||||
self.progressListener.onTaskProgress(UploadApiTaskProgress(id: taskIdStr, progress: progress))
|
||||
}
|
||||
|
||||
let request = RequestRef()
|
||||
let timeoutTask = Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: TaskConfig.downloadCheckIntervalNs)
|
||||
request.didStall = Date().timeIntervalSince(lastProgressTime) > TaskConfig.downloadTimeoutS
|
||||
if request.didStall {
|
||||
if let requestId = request.id {
|
||||
resourceManager.cancelDataRequest(requestId)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
var hasher = task.checksum == nil && task.livePhotoVideoId == nil ? Insecure.SHA1() : nil
|
||||
request.id = resourceManager.requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { data in
|
||||
guard let requestId = request.id else { return }
|
||||
do {
|
||||
hasher?.update(data: data)
|
||||
try fileHandle.write(contentsOf: data)
|
||||
} catch {
|
||||
request.id = nil
|
||||
resourceManager.cancelDataRequest(requestId)
|
||||
}
|
||||
},
|
||||
completionHandler: { error in
|
||||
timeoutTask.cancel()
|
||||
switch error {
|
||||
case let e as NSError where e.domain == "CloudPhotoLibraryErrorDomain":
|
||||
dPrint("iCloud error during download: \(e)")
|
||||
let code: UploadErrorCode =
|
||||
switch e.code {
|
||||
case 1005: .iCloudRateLimit
|
||||
case 81: .iCloudThrottled
|
||||
default: .photosUnknownError
|
||||
}
|
||||
continuation.resume(throwing: UploadError.iCloudError(code))
|
||||
case let e as PHPhotosError:
|
||||
dPrint("Photos error during download: \(e)")
|
||||
let code: UploadErrorCode =
|
||||
switch e.code {
|
||||
case .notEnoughSpace: .notEnoughSpace
|
||||
case .missingResource: .resourceNotFound
|
||||
case .networkError: .networkError
|
||||
case .internalError: .photosInternalError
|
||||
case .invalidResource: .invalidResource
|
||||
case .operationInterrupted: .interrupted
|
||||
case .userCancelled where request.didStall: .downloadStalled
|
||||
case .userCancelled: .cancelled
|
||||
default: .photosUnknownError
|
||||
}
|
||||
continuation.resume(throwing: UploadError.photosError(code))
|
||||
case .some:
|
||||
dPrint("Unknown error during download: \(String(describing: error))")
|
||||
continuation.resume(throwing: UploadError.unknown)
|
||||
case .none:
|
||||
dPrint("Download completed for task \(task.taskId)")
|
||||
do {
|
||||
try fileHandle.write(contentsOf: footer)
|
||||
continuation.resume(returning: hasher.map { hasher in Data(hasher.finalize()).base64EncodedString() })
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: filePath)
|
||||
continuation.resume(throwing: UploadError.fileCreationFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} onCancel: {
|
||||
if let requestId = request.id {
|
||||
resourceManager.cancelDataRequest(requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFailure(task: LocalAssetDownloadData, code: UploadErrorCode, filePath: URL? = nil) async {
|
||||
dPrint("Handling failure for task \(task.taskId) with code \(code.rawValue)")
|
||||
do {
|
||||
if let filePath {
|
||||
try? FileManager.default.removeItem(at: filePath)
|
||||
}
|
||||
|
||||
try await taskRepository.retryOrFail(taskId: task.taskId, code: code, status: .downloadFailed)
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(
|
||||
id: String(task.taskId),
|
||||
filename: task.filename,
|
||||
status: .downloadFailed
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
dPrint("Failed to update download failure status for task \(task.taskId): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
65
mobile/ios/Runner/Upload/Listeners.swift
Normal file
65
mobile/ios/Runner/Upload/Listeners.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
protocol TaskProgressListener {
|
||||
func onTaskProgress(_ event: UploadApiTaskProgress)
|
||||
}
|
||||
|
||||
protocol TaskStatusListener {
|
||||
func onTaskStatus(_ event: UploadApiTaskStatus)
|
||||
}
|
||||
|
||||
final class StatusEventListener: StreamStatusStreamHandler, TaskStatusListener, @unchecked Sendable {
|
||||
var eventSink: PigeonEventSink<UploadApiTaskStatus>?
|
||||
|
||||
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskStatus>) {
|
||||
eventSink = sink
|
||||
}
|
||||
|
||||
func onTaskStatus(_ event: UploadApiTaskStatus) {
|
||||
if let eventSink {
|
||||
DispatchQueue.main.async { eventSink.success(event) }
|
||||
}
|
||||
}
|
||||
|
||||
func onEventsDone() {
|
||||
DispatchQueue.main.async {
|
||||
self.eventSink?.endOfStream()
|
||||
self.eventSink = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ProgressEventListener: StreamProgressStreamHandler, TaskProgressListener, @unchecked Sendable {
|
||||
var eventSink: PigeonEventSink<UploadApiTaskProgress>?
|
||||
private var lastReportTimes: [String: Date] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskProgress>) {
|
||||
eventSink = sink
|
||||
}
|
||||
|
||||
func onTaskProgress(_ event: UploadApiTaskProgress) {
|
||||
guard let eventSink,
|
||||
lock.withLock({
|
||||
let now = Date()
|
||||
if let lastReport = lastReportTimes[event.id] {
|
||||
guard now.timeIntervalSince(lastReport) >= TaskConfig.progressThrottleInterval else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
lastReportTimes[event.id] = now
|
||||
return true
|
||||
})
|
||||
else { return }
|
||||
|
||||
DispatchQueue.main.async { eventSink.success(event) }
|
||||
}
|
||||
|
||||
func onEventsDone() {
|
||||
DispatchQueue.main.async {
|
||||
self.eventSink?.endOfStream()
|
||||
self.eventSink = nil
|
||||
self.lock.withLock {
|
||||
self.lastReportTimes.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
mobile/ios/Runner/Upload/NetworkMonitor.swift
Normal file
22
mobile/ios/Runner/Upload/NetworkMonitor.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Network
|
||||
|
||||
final class NetworkMonitor {
|
||||
static let shared = NetworkMonitor()
|
||||
private let monitor = NWPathMonitor()
|
||||
private(set) var isConnected = false
|
||||
private(set) var isExpensive = false
|
||||
|
||||
private init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
guard let self else { return }
|
||||
let wasConnected = self.isConnected
|
||||
self.isConnected = path.status == .satisfied
|
||||
self.isExpensive = path.isExpensive
|
||||
|
||||
if !wasConnected && self.isConnected {
|
||||
NotificationCenter.default.post(name: .networkDidConnect, object: nil)
|
||||
}
|
||||
}
|
||||
monitor.start(queue: .global(qos: .default))
|
||||
}
|
||||
}
|
||||
197
mobile/ios/Runner/Upload/UploadQueue.swift
Normal file
197
mobile/ios/Runner/Upload/UploadQueue.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
private var queueProcessingTask: Task<Void, Never>?
|
||||
private var queueProcessingLock = NSLock()
|
||||
|
||||
final class UploadQueue<StoreRepo: StoreProtocol, TaskRepo: TaskProtocol, StatusListener: TaskStatusListener> {
|
||||
private let storeRepository: StoreRepo
|
||||
private let taskRepository: TaskRepo
|
||||
private let statusListener: StatusListener
|
||||
|
||||
private let cellularSession: URLSession
|
||||
private let wifiOnlySession: URLSession
|
||||
private var uploadObserver: NSObjectProtocol?
|
||||
private var downloadObserver: NSObjectProtocol?
|
||||
private var networkObserver: NSObjectProtocol?
|
||||
|
||||
init(
|
||||
storeRepository: StoreRepo,
|
||||
taskRepository: TaskRepo,
|
||||
statusListener: StatusListener,
|
||||
cellularSession: URLSession,
|
||||
wifiOnlySession: URLSession
|
||||
) {
|
||||
self.storeRepository = storeRepository
|
||||
self.taskRepository = taskRepository
|
||||
self.cellularSession = cellularSession
|
||||
self.wifiOnlySession = wifiOnlySession
|
||||
self.statusListener = statusListener
|
||||
|
||||
uploadObserver = NotificationCenter.default.addObserver(forName: .uploadTaskDidComplete, object: nil, queue: nil) {
|
||||
[weak self] _ in
|
||||
self?.startQueueProcessing()
|
||||
}
|
||||
downloadObserver = NotificationCenter.default.addObserver(
|
||||
forName: .downloadTaskDidComplete,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { [weak self] _ in
|
||||
self?.startQueueProcessing()
|
||||
}
|
||||
networkObserver = NotificationCenter.default.addObserver(forName: .networkDidConnect, object: nil, queue: nil) {
|
||||
[weak self] _ in
|
||||
self?.startQueueProcessing()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
uploadObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||
downloadObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||
networkObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||
}
|
||||
|
||||
func enqueueFiles(paths: [String]) async throws {
|
||||
guard !paths.isEmpty else { return dPrint("No paths to enqueue") }
|
||||
|
||||
guard let deviceId = try? storeRepository.get(StoreKey.deviceId) else {
|
||||
throw StoreError.notFound
|
||||
}
|
||||
|
||||
defer { startQueueProcessing() }
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self, returning: Void.self) { group in
|
||||
let date = Date()
|
||||
try FileManager.default.createDirectory(
|
||||
at: TaskConfig.originalsDir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
|
||||
for path in paths {
|
||||
group.addTask {
|
||||
let inputURL = URL(fileURLWithPath: path, isDirectory: false)
|
||||
let outputURL = TaskConfig.originalsDir.appendingPathComponent(UUID().uuidString)
|
||||
let resources = try inputURL.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey])
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let (header, footer) = AssetData(
|
||||
deviceAssetId: "",
|
||||
deviceId: deviceId,
|
||||
fileCreatedAt: formatter.string(from: resources.creationDate ?? date),
|
||||
fileModifiedAt: formatter.string(from: resources.contentModificationDate ?? date),
|
||||
fileName: resources.name ?? inputURL.lastPathComponent,
|
||||
isFavorite: false,
|
||||
livePhotoVideoId: nil
|
||||
).multipart()
|
||||
|
||||
do {
|
||||
let writeHandle = try FileHandle.createOrOverwrite(atPath: outputURL.path)
|
||||
try writeHandle.write(contentsOf: header)
|
||||
let readHandle = try FileHandle(forReadingFrom: inputURL)
|
||||
|
||||
let bufferSize = 1024 * 1024
|
||||
while true {
|
||||
let data = try readHandle.read(upToCount: bufferSize)
|
||||
guard let data = data, !data.isEmpty else { break }
|
||||
try writeHandle.write(contentsOf: data)
|
||||
}
|
||||
try writeHandle.write(contentsOf: footer)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try await group.waitForAll()
|
||||
}
|
||||
|
||||
try await taskRepository.enqueue(files: paths)
|
||||
dPrint("Enqueued \(paths.count) assets for upload")
|
||||
}
|
||||
|
||||
func startQueueProcessing() {
|
||||
dPrint("Starting upload queue processing")
|
||||
queueProcessingLock.withLock {
|
||||
guard queueProcessingTask == nil else { return }
|
||||
queueProcessingTask = Task {
|
||||
await startUploads()
|
||||
queueProcessingLock.withLock { queueProcessingTask = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startUploads() async {
|
||||
dPrint("Processing upload queue")
|
||||
guard NetworkMonitor.shared.isConnected,
|
||||
let backupEnabled = try? storeRepository.get(StoreKey.enableBackup), backupEnabled,
|
||||
let url = try? storeRepository.get(StoreKey.serverEndpoint),
|
||||
let accessToken = try? storeRepository.get(StoreKey.accessToken)
|
||||
else { return dPrint("Upload queue paused: missing preconditions") }
|
||||
|
||||
do {
|
||||
let tasks = try await taskRepository.getUploadTasks()
|
||||
if tasks.isEmpty { return dPrint("No upload tasks to process") }
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for task in tasks {
|
||||
group.addTask { await self.startUpload(multipart: task, url: url, accessToken: accessToken) }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
dPrint("Upload queue error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func startUpload(multipart task: LocalAssetUploadData, url: URL, accessToken: String) async {
|
||||
dPrint("Uploading asset resource at \(task.filePath) of task \(task.taskId)")
|
||||
defer { startQueueProcessing() }
|
||||
|
||||
let session =
|
||||
switch task.type {
|
||||
case .image:
|
||||
(try? storeRepository.get(StoreKey.useWifiForUploadPhotos)) ?? false ? wifiOnlySession : cellularSession
|
||||
case .video:
|
||||
(try? storeRepository.get(StoreKey.useWifiForUploadVideos)) ?? false ? wifiOnlySession : cellularSession
|
||||
default: wifiOnlySession
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url.appendingPathComponent("/assets"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(accessToken, forHTTPHeaderField: UploadHeaders.userToken.rawValue)
|
||||
request.setValue(AssetData.contentType, forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let sessionTask = session.uploadTask(with: request, fromFile: task.filePath)
|
||||
sessionTask.taskDescription = String(task.taskId)
|
||||
sessionTask.priority = task.priority
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: task.filePath) // upload task already copied the file
|
||||
try await taskRepository.markUploadQueued(taskId: task.taskId)
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(
|
||||
id: String(task.taskId),
|
||||
filename: task.filename,
|
||||
status: .uploadQueued,
|
||||
)
|
||||
)
|
||||
|
||||
sessionTask.resume()
|
||||
dPrint("Upload started for task \(task.taskId) using \(session == wifiOnlySession ? "WiFi" : "Cellular") session")
|
||||
} catch {
|
||||
dPrint("Upload failed for \(task.taskId), could not update queue status: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFailure(task: LocalAssetUploadData, code: UploadErrorCode) async {
|
||||
do {
|
||||
try await taskRepository.retryOrFail(taskId: task.taskId, code: code, status: .uploadFailed)
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(
|
||||
id: String(task.taskId),
|
||||
filename: task.filename,
|
||||
status: .uploadFailed
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
dPrint("Failed to update upload failure status for task \(task.taskId): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
482
mobile/ios/Runner/Upload/UploadTask.g.swift
Normal file
482
mobile/ios/Runner/Upload/UploadTask.g.swift
Normal file
@@ -0,0 +1,482 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), 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 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?
|
||||
}
|
||||
|
||||
func deepEqualsUploadTask(_ lhs: Any?, _ rhs: Any?) -> Bool {
|
||||
let cleanLhs = nilOrValue(lhs) as Any?
|
||||
let cleanRhs = nilOrValue(rhs) as Any?
|
||||
switch (cleanLhs, cleanRhs) {
|
||||
case (nil, nil):
|
||||
return true
|
||||
|
||||
case (nil, _), (_, nil):
|
||||
return false
|
||||
|
||||
case is (Void, Void):
|
||||
return true
|
||||
|
||||
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
|
||||
return cleanLhsHashable == cleanRhsHashable
|
||||
|
||||
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
|
||||
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
|
||||
for (index, element) in cleanLhsArray.enumerated() {
|
||||
if !deepEqualsUploadTask(element, cleanRhsArray[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
|
||||
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
|
||||
for (key, cleanLhsValue) in cleanLhsDictionary {
|
||||
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
|
||||
if !deepEqualsUploadTask(cleanLhsValue, cleanRhsDictionary[key]!) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
default:
|
||||
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deepHashUploadTask(value: Any?, hasher: inout Hasher) {
|
||||
if let valueList = value as? [AnyHashable] {
|
||||
for item in valueList { deepHashUploadTask(value: item, hasher: &hasher) }
|
||||
return
|
||||
}
|
||||
|
||||
if let valueDict = value as? [AnyHashable: AnyHashable] {
|
||||
for key in valueDict.keys {
|
||||
hasher.combine(key)
|
||||
deepHashUploadTask(value: valueDict[key]!, hasher: &hasher)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let hashableValue = value as? AnyHashable {
|
||||
hasher.combine(hashableValue.hashValue)
|
||||
}
|
||||
|
||||
return hasher.combine(String(describing: value))
|
||||
}
|
||||
|
||||
|
||||
|
||||
enum UploadApiErrorCode: Int {
|
||||
case unknown = 0
|
||||
case assetNotFound = 1
|
||||
case fileNotFound = 2
|
||||
case resourceNotFound = 3
|
||||
case invalidResource = 4
|
||||
case encodingFailed = 5
|
||||
case writeFailed = 6
|
||||
case notEnoughSpace = 7
|
||||
case networkError = 8
|
||||
case photosInternalError = 9
|
||||
case photosUnknownError = 10
|
||||
case interrupted = 11
|
||||
case cancelled = 12
|
||||
case downloadStalled = 13
|
||||
case forceQuit = 14
|
||||
case outOfResources = 15
|
||||
case backgroundUpdatesDisabled = 16
|
||||
case uploadTimeout = 17
|
||||
case iCloudRateLimit = 18
|
||||
case iCloudThrottled = 19
|
||||
case invalidResponse = 20
|
||||
case badRequest = 21
|
||||
case internalServerError = 22
|
||||
case unauthorized = 23
|
||||
}
|
||||
|
||||
enum UploadApiStatus: Int {
|
||||
case downloadPending = 0
|
||||
case downloadQueued = 1
|
||||
case downloadFailed = 2
|
||||
case uploadPending = 3
|
||||
case uploadQueued = 4
|
||||
case uploadFailed = 5
|
||||
case uploadComplete = 6
|
||||
case uploadSkipped = 7
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct UploadApiTaskStatus: Hashable {
|
||||
var id: String
|
||||
var filename: String
|
||||
var status: UploadApiStatus
|
||||
var errorCode: UploadApiErrorCode? = nil
|
||||
var httpStatusCode: Int64? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> UploadApiTaskStatus? {
|
||||
let id = pigeonVar_list[0] as! String
|
||||
let filename = pigeonVar_list[1] as! String
|
||||
let status = pigeonVar_list[2] as! UploadApiStatus
|
||||
let errorCode: UploadApiErrorCode? = nilOrValue(pigeonVar_list[3])
|
||||
let httpStatusCode: Int64? = nilOrValue(pigeonVar_list[4])
|
||||
|
||||
return UploadApiTaskStatus(
|
||||
id: id,
|
||||
filename: filename,
|
||||
status: status,
|
||||
errorCode: errorCode,
|
||||
httpStatusCode: httpStatusCode
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
id,
|
||||
filename,
|
||||
status,
|
||||
errorCode,
|
||||
httpStatusCode,
|
||||
]
|
||||
}
|
||||
static func == (lhs: UploadApiTaskStatus, rhs: UploadApiTaskStatus) -> Bool {
|
||||
return deepEqualsUploadTask(lhs.toList(), rhs.toList()) }
|
||||
func hash(into hasher: inout Hasher) {
|
||||
deepHashUploadTask(value: toList(), hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct UploadApiTaskProgress: Hashable {
|
||||
var id: String
|
||||
var progress: Double
|
||||
var speed: Double? = nil
|
||||
var totalBytes: Int64? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> UploadApiTaskProgress? {
|
||||
let id = pigeonVar_list[0] as! String
|
||||
let progress = pigeonVar_list[1] as! Double
|
||||
let speed: Double? = nilOrValue(pigeonVar_list[2])
|
||||
let totalBytes: Int64? = nilOrValue(pigeonVar_list[3])
|
||||
|
||||
return UploadApiTaskProgress(
|
||||
id: id,
|
||||
progress: progress,
|
||||
speed: speed,
|
||||
totalBytes: totalBytes
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
id,
|
||||
progress,
|
||||
speed,
|
||||
totalBytes,
|
||||
]
|
||||
}
|
||||
static func == (lhs: UploadApiTaskProgress, rhs: UploadApiTaskProgress) -> Bool {
|
||||
return deepEqualsUploadTask(lhs.toList(), rhs.toList()) }
|
||||
func hash(into hasher: inout Hasher) {
|
||||
deepHashUploadTask(value: toList(), hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadTaskPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return UploadApiErrorCode(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return UploadApiStatus(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 131:
|
||||
return UploadApiTaskStatus.fromList(self.readValue() as! [Any?])
|
||||
case 132:
|
||||
return UploadApiTaskProgress.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadTaskPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? UploadApiErrorCode {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? UploadApiStatus {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? UploadApiTaskStatus {
|
||||
super.writeByte(131)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? UploadApiTaskProgress {
|
||||
super.writeByte(132)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadTaskPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
override func reader(with data: Data) -> FlutterStandardReader {
|
||||
return UploadTaskPigeonCodecReader(data: data)
|
||||
}
|
||||
|
||||
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||
return UploadTaskPigeonCodecWriter(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
class UploadTaskPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = UploadTaskPigeonCodec(readerWriter: UploadTaskPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
var uploadTaskPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: UploadTaskPigeonCodecReaderWriter());
|
||||
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol UploadApi {
|
||||
func initialize(completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func refresh(completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func cancelAll(completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func enqueueAssets(localIds: [String], completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func enqueueFiles(paths: [String], completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func onConfigChange(key: Int64, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class UploadApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { UploadTaskPigeonCodec.shared }
|
||||
/// Sets up an instance of `UploadApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: UploadApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let initializeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.initialize\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
initializeChannel.setMessageHandler { _, reply in
|
||||
api.initialize { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
initializeChannel.setMessageHandler(nil)
|
||||
}
|
||||
let refreshChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.refresh\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
refreshChannel.setMessageHandler { _, reply in
|
||||
api.refresh { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
refreshChannel.setMessageHandler(nil)
|
||||
}
|
||||
let cancelAllChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.cancelAll\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
cancelAllChannel.setMessageHandler { _, reply in
|
||||
api.cancelAll { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cancelAllChannel.setMessageHandler(nil)
|
||||
}
|
||||
let enqueueAssetsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
enqueueAssetsChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let localIdsArg = args[0] as! [String]
|
||||
api.enqueueAssets(localIds: localIdsArg) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enqueueAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let enqueueFilesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueFiles\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
enqueueFilesChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let pathsArg = args[0] as! [String]
|
||||
api.enqueueFiles(paths: pathsArg) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enqueueFilesChannel.setMessageHandler(nil)
|
||||
}
|
||||
let onConfigChangeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.onConfigChange\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
onConfigChangeChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let keyArg = args[0] as! Int64
|
||||
api.onConfigChange(key: keyArg) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onConfigChangeChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PigeonStreamHandler<ReturnType>: NSObject, FlutterStreamHandler {
|
||||
private let wrapper: PigeonEventChannelWrapper<ReturnType>
|
||||
private var pigeonSink: PigeonEventSink<ReturnType>? = nil
|
||||
|
||||
init(wrapper: PigeonEventChannelWrapper<ReturnType>) {
|
||||
self.wrapper = wrapper
|
||||
}
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||
-> FlutterError?
|
||||
{
|
||||
pigeonSink = PigeonEventSink<ReturnType>(events)
|
||||
wrapper.onListen(withArguments: arguments, sink: pigeonSink!)
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
pigeonSink = nil
|
||||
wrapper.onCancel(withArguments: arguments)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
class PigeonEventChannelWrapper<ReturnType> {
|
||||
func onListen(withArguments arguments: Any?, sink: PigeonEventSink<ReturnType>) {}
|
||||
func onCancel(withArguments arguments: Any?) {}
|
||||
}
|
||||
|
||||
class PigeonEventSink<ReturnType> {
|
||||
private let sink: FlutterEventSink
|
||||
|
||||
init(_ sink: @escaping FlutterEventSink) {
|
||||
self.sink = sink
|
||||
}
|
||||
|
||||
func success(_ value: ReturnType) {
|
||||
sink(value)
|
||||
}
|
||||
|
||||
func error(code: String, message: String?, details: Any?) {
|
||||
sink(FlutterError(code: code, message: message, details: details))
|
||||
}
|
||||
|
||||
func endOfStream() {
|
||||
sink(FlutterEndOfEventStream)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class StreamStatusStreamHandler: PigeonEventChannelWrapper<UploadApiTaskStatus> {
|
||||
static func register(with messenger: FlutterBinaryMessenger,
|
||||
instanceName: String = "",
|
||||
streamHandler: StreamStatusStreamHandler) {
|
||||
var channelName = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamStatus"
|
||||
if !instanceName.isEmpty {
|
||||
channelName += ".\(instanceName)"
|
||||
}
|
||||
let internalStreamHandler = PigeonStreamHandler<UploadApiTaskStatus>(wrapper: streamHandler)
|
||||
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: uploadTaskPigeonMethodCodec)
|
||||
channel.setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
|
||||
class StreamProgressStreamHandler: PigeonEventChannelWrapper<UploadApiTaskProgress> {
|
||||
static func register(with messenger: FlutterBinaryMessenger,
|
||||
instanceName: String = "",
|
||||
streamHandler: StreamProgressStreamHandler) {
|
||||
var channelName = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamProgress"
|
||||
if !instanceName.isEmpty {
|
||||
channelName += ".\(instanceName)"
|
||||
}
|
||||
let internalStreamHandler = PigeonStreamHandler<UploadApiTaskProgress>(wrapper: streamHandler)
|
||||
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: uploadTaskPigeonMethodCodec)
|
||||
channel.setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
|
||||
192
mobile/ios/Runner/Upload/UploadTask.swift
Normal file
192
mobile/ios/Runner/Upload/UploadTask.swift
Normal file
@@ -0,0 +1,192 @@
|
||||
import SQLiteData
|
||||
|
||||
extension FileHandle {
|
||||
static func createOrOverwrite(atPath path: String) throws -> FileHandle {
|
||||
let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0o644)
|
||||
guard fd >= 0 else {
|
||||
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
|
||||
}
|
||||
return FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||
}
|
||||
}
|
||||
|
||||
final class UploadApiImpl<
|
||||
StoreRepo: StoreProtocol,
|
||||
TaskRepo: TaskProtocol,
|
||||
StatusListener: TaskStatusListener,
|
||||
ProgressListener: TaskProgressListener
|
||||
>: ImmichPlugin, UploadApi {
|
||||
private let storeRepository: StoreRepo
|
||||
private let taskRepository: TaskRepo
|
||||
private let downloadQueue: DownloadQueue<StoreRepo, TaskRepo, StatusListener, ProgressListener>
|
||||
private let uploadQueue: UploadQueue<StoreRepo, TaskRepo, StatusListener>
|
||||
|
||||
private var isInitialized = false
|
||||
private let initLock = NSLock()
|
||||
|
||||
private var backupTask: Task<Void, Never>?
|
||||
private let backupLock = NSLock()
|
||||
private let cellularSession: URLSession
|
||||
private let wifiOnlySession: URLSession
|
||||
|
||||
init(
|
||||
storeRepository: StoreRepo,
|
||||
taskRepository: TaskRepo,
|
||||
statusListener: StatusListener,
|
||||
progressListener: ProgressListener
|
||||
) {
|
||||
self.taskRepository = taskRepository
|
||||
let delegate = UploadApiDelegate(
|
||||
taskRepository: taskRepository,
|
||||
statusListener: statusListener,
|
||||
progressListener: progressListener
|
||||
)
|
||||
let cellularConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).cellular")
|
||||
cellularConfig.allowsCellularAccess = true
|
||||
cellularConfig.waitsForConnectivity = true
|
||||
|
||||
self.cellularSession = URLSession(configuration: cellularConfig, delegate: delegate, delegateQueue: nil)
|
||||
|
||||
let wifiOnlyConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).wifi")
|
||||
wifiOnlyConfig.allowsCellularAccess = false
|
||||
wifiOnlyConfig.waitsForConnectivity = true
|
||||
self.wifiOnlySession = URLSession(configuration: wifiOnlyConfig, delegate: delegate, delegateQueue: nil)
|
||||
|
||||
self.storeRepository = storeRepository
|
||||
self.uploadQueue = UploadQueue(
|
||||
storeRepository: storeRepository,
|
||||
taskRepository: taskRepository,
|
||||
statusListener: statusListener,
|
||||
cellularSession: cellularSession,
|
||||
wifiOnlySession: wifiOnlySession
|
||||
)
|
||||
self.downloadQueue = DownloadQueue(
|
||||
storeRepository: storeRepository,
|
||||
taskRepository: taskRepository,
|
||||
statusListener: statusListener,
|
||||
progressListener: progressListener
|
||||
)
|
||||
}
|
||||
|
||||
func initialize(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task(priority: .high) {
|
||||
do {
|
||||
async let dbIds = taskRepository.getTaskIds(status: .uploadQueued)
|
||||
async let cellularTasks = cellularSession.allTasks
|
||||
async let wifiTasks = wifiOnlySession.allTasks
|
||||
|
||||
var dbTaskIds = Set(try await dbIds)
|
||||
func validateTasks(_ tasks: [URLSessionTask]) {
|
||||
for task in tasks {
|
||||
if let taskIdStr = task.taskDescription, let taskId = Int64(taskIdStr), task.state != .canceling {
|
||||
dbTaskIds.remove(taskId)
|
||||
} else {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateTasks(await cellularTasks)
|
||||
validateTasks(await wifiTasks)
|
||||
|
||||
try await taskRepository.markOrphansPending(ids: Array(dbTaskIds))
|
||||
|
||||
try? FileManager.default.removeItem(at: TaskConfig.originalsDir)
|
||||
initLock.withLock { isInitialized = true }
|
||||
startBackup()
|
||||
self.completeWhenActive(for: completion, with: .success(()))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refresh(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task {
|
||||
startBackup()
|
||||
self.completeWhenActive(for: completion, with: .success(()))
|
||||
}
|
||||
}
|
||||
|
||||
func startBackup() {
|
||||
dPrint("Starting backup task")
|
||||
guard (initLock.withLock { isInitialized }) else { return dPrint("Not initialized, skipping backup") }
|
||||
|
||||
backupLock.withLock {
|
||||
guard backupTask == nil else { return dPrint("Backup task already running") }
|
||||
backupTask = Task {
|
||||
await _startBackup()
|
||||
backupLock.withLock { backupTask = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAll(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task {
|
||||
async let cellularTasks = cellularSession.allTasks
|
||||
async let wifiTasks = wifiOnlySession.allTasks
|
||||
|
||||
cancelSessionTasks(await cellularTasks)
|
||||
cancelSessionTasks(await wifiTasks)
|
||||
self.completeWhenActive(for: completion, with: .success(()))
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueAssets(localIds: [String], completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
try await downloadQueue.enqueueAssets(localIds: localIds)
|
||||
self.completeWhenActive(for: completion, with: .success(()))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueFiles(paths: [String], completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
try await uploadQueue.enqueueFiles(paths: paths)
|
||||
self.completeWhenActive(for: completion, with: .success(()))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onConfigChange(key: Int64, completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
storeRepository.invalidateCache()
|
||||
Task {
|
||||
if let key = StoreKey(rawValue: Int(key)), key == ._accessToken {
|
||||
try? await taskRepository.resolveError(code: .unauthorized)
|
||||
}
|
||||
startBackup()
|
||||
self.completeWhenActive(for: completion, with: .success(()))
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelSessionTasks(_ tasks: [URLSessionTask]) {
|
||||
dPrint("Canceling \(tasks.count) tasks")
|
||||
for task in tasks {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func _startBackup() async {
|
||||
defer {
|
||||
downloadQueue.startQueueProcessing()
|
||||
uploadQueue.startQueueProcessing()
|
||||
}
|
||||
|
||||
do {
|
||||
let candidates = try await taskRepository.getBackupCandidates()
|
||||
|
||||
guard !candidates.isEmpty else { return dPrint("No candidates for backup") }
|
||||
|
||||
try await taskRepository.enqueue(assets: candidates, imagePriority: 0.5, videoPriority: 0.3)
|
||||
dPrint("Backup enqueued \(candidates.count) assets for upload")
|
||||
} catch {
|
||||
print("Backup queue error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>IntentsSupported</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>IntentsSupported</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
|
||||
$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY
|
||||
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY
|
||||
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY
|
||||
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0
|
||||
).@count > 0 </string>
|
||||
<key>PHSupportedMediaTypes</key>
|
||||
<array>
|
||||
<string>Video</string>
|
||||
<string>Image</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
<key>PHSupportedMediaTypes</key>
|
||||
<array>
|
||||
<string>Video</string>
|
||||
<string>Image</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.immich.share</string>
|
||||
</array>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.immich.share</string>
|
||||
</array>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -5,10 +5,6 @@ class LocalAsset extends BaseAsset {
|
||||
final String? remoteAssetId;
|
||||
final int orientation;
|
||||
|
||||
final DateTime? adjustmentTime;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
const LocalAsset({
|
||||
required this.id,
|
||||
String? remoteId,
|
||||
@@ -23,9 +19,6 @@ class LocalAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
@@ -40,8 +33,6 @@ class LocalAsset extends BaseAsset {
|
||||
@override
|
||||
String get heroTag => '${id}_${remoteId ?? checksum}';
|
||||
|
||||
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''LocalAsset {
|
||||
@@ -56,9 +47,6 @@ class LocalAsset extends BaseAsset {
|
||||
remoteId: ${remoteId ?? "<NA>"}
|
||||
isFavorite: $isFavorite,
|
||||
orientation: $orientation,
|
||||
adjustmentTime: $adjustmentTime,
|
||||
latitude: ${latitude ?? "<NA>"},
|
||||
longitude: ${longitude ?? "<NA>"},
|
||||
}''';
|
||||
}
|
||||
|
||||
@@ -67,23 +55,11 @@ class LocalAsset extends BaseAsset {
|
||||
bool operator ==(Object other) {
|
||||
if (other is! LocalAsset) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return super == other &&
|
||||
id == other.id &&
|
||||
orientation == other.orientation &&
|
||||
adjustmentTime == other.adjustmentTime &&
|
||||
latitude == other.latitude &&
|
||||
longitude == other.longitude;
|
||||
return super == other && id == other.id && orientation == other.orientation;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
super.hashCode ^
|
||||
id.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
orientation.hashCode ^
|
||||
adjustmentTime.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode;
|
||||
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
|
||||
|
||||
LocalAsset copyWith({
|
||||
String? id,
|
||||
@@ -98,9 +74,6 @@ class LocalAsset extends BaseAsset {
|
||||
int? durationInSeconds,
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -115,9 +88,6 @@ class LocalAsset extends BaseAsset {
|
||||
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
|
||||
// Timeline Events
|
||||
class TimelineReloadEvent extends Event {
|
||||
const TimelineReloadEvent();
|
||||
}
|
||||
|
||||
class ScrollToTopEvent extends Event {
|
||||
const ScrollToTopEvent();
|
||||
}
|
||||
|
||||
class ScrollToDateEvent extends Event {
|
||||
final DateTime date;
|
||||
|
||||
const ScrollToDateEvent(this.date);
|
||||
}
|
||||
|
||||
// Asset Viewer Events
|
||||
class ViewerOpenBottomSheetEvent extends Event {
|
||||
final bool activitiesMode;
|
||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
||||
}
|
||||
|
||||
class ViewerReloadAssetEvent extends Event {
|
||||
const ViewerReloadAssetEvent();
|
||||
}
|
||||
|
||||
// Multi-Select Events
|
||||
class MultiSelectToggleEvent extends Event {
|
||||
final bool isEnabled;
|
||||
const MultiSelectToggleEvent(this.isEnabled);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class ExifInfo {
|
||||
|
||||
String get fNumber => f == null ? "" : f!.toStringAsFixed(1);
|
||||
|
||||
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3);
|
||||
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(1);
|
||||
|
||||
const ExifInfo({
|
||||
this.assetId,
|
||||
|
||||
@@ -71,7 +71,6 @@ enum StoreKey<T> {
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
|
||||
autoPlayVideo<bool>._(139),
|
||||
albumGridView<bool>._(140),
|
||||
|
||||
// Experimental stuff
|
||||
photoManagerCustomFilter<bool>._(1000),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user