Compare commits
25 Commits
refactor/m
...
feat/mise-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4202dd6c9a | ||
|
|
7bd79b551c | ||
|
|
5fe954b3c9 | ||
|
|
7f81a5bd6f | ||
|
|
37a79292c0 | ||
|
|
bf6211776f | ||
|
|
6c178a04dc | ||
|
|
036d314cb6 | ||
|
|
1fc5da398a | ||
|
|
4d84338086 | ||
|
|
0ac49b00ee | ||
|
|
e427778a96 | ||
|
|
b82e29fbb4 | ||
|
|
ff19aea4ac | ||
|
|
28179a3a1d | ||
|
|
af1e18d07e | ||
|
|
270a0ff986 | ||
|
|
9d3f10372d | ||
|
|
2f1385a236 | ||
|
|
183a285584 | ||
|
|
5ce946bb5b | ||
|
|
674faf2e57 | ||
|
|
4f7702c6bf | ||
|
|
28edf5664d | ||
|
|
ec2f94cae8 |
1
.github/.nvmrc
vendored
1
.github/.nvmrc
vendored
@@ -1 +0,0 @@
|
||||
22.18.0
|
||||
5
.github/package.json
vendored
5
.github/package.json
vendored
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"scripts": {
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write ."
|
||||
},
|
||||
"name": "github",
|
||||
"devDependencies": {
|
||||
"prettier": "^3.5.3"
|
||||
}
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -34,3 +34,7 @@ The `/api/something` endpoint is now `/api/something-else`
|
||||
- [ ] I have followed naming conventions/patterns in the surrounding code
|
||||
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
|
||||
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
|
||||
|
||||
## Please describe to which degree, if any, an LLM was used in creating this pull request.
|
||||
|
||||
...
|
||||
|
||||
26
.github/workflows/cli.yml
vendored
26
.github/workflows/cli.yml
vendored
@@ -33,24 +33,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
|
||||
- name: Setup typescript-sdk
|
||||
run: pnpm install && pnpm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
run: mise run sdk:install && mise run sdk:build
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: pnpm publish
|
||||
- name: Install dependencies
|
||||
run: mise run cli:install
|
||||
|
||||
- name: Run build
|
||||
run: mise run cli:build
|
||||
|
||||
- name: Publish package
|
||||
run: pnpm publish
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
17
.github/workflows/docs-build.yml
vendored
17
.github/workflows/docs-build.yml
vendored
@@ -55,24 +55,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './docs/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
|
||||
- name: Run install
|
||||
run: pnpm install
|
||||
run: mise run docs:install
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm format
|
||||
run: mise run docs:format-fix
|
||||
|
||||
- name: Run build
|
||||
run: pnpm build
|
||||
run: mise run docs:build
|
||||
|
||||
- name: Upload build output
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
|
||||
10
.github/workflows/fix-format.yml
vendored
10
.github/workflows/fix-format.yml
vendored
@@ -28,15 +28,11 @@ jobs:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
|
||||
- name: Fix formatting
|
||||
run: make install-all && make format-all
|
||||
run: mise run server:format-fix && mise run web:format-fix && mise run docs:format-fix
|
||||
|
||||
- name: Commit and push
|
||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||
|
||||
11
.github/workflows/prepare-release.yml
vendored
11
.github/workflows/prepare-release.yml
vendored
@@ -46,15 +46,8 @@ jobs:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
|
||||
- name: Bump version
|
||||
env:
|
||||
|
||||
17
.github/workflows/sdk.yml
vendored
17
.github/workflows/sdk.yml
vendored
@@ -20,20 +20,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: mise run sdk:install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
run: mise run sdk:build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm publish
|
||||
env:
|
||||
|
||||
270
.github/workflows/test.yml
vendored
270
.github/workflows/test.yml
vendored
@@ -72,27 +72,21 @@ jobs:
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Run package manager install
|
||||
run: pnpm install
|
||||
run: mise run server:install
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
run: mise run server:lint
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
run: mise run server:format
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run tsc
|
||||
run: pnpm check
|
||||
run: mise run server:check
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run small tests & coverage
|
||||
run: pnpm test
|
||||
run: mise run server:test
|
||||
if: ${{ !cancelled() }}
|
||||
cli-unit-tests:
|
||||
name: Unit Test CLI
|
||||
@@ -109,30 +103,23 @@ jobs:
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Setup typescript-sdk
|
||||
run: pnpm install && pnpm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
run: mise run sdk:install && mise run sdk:build
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
run: mise run cli:install
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
run: mise run cli:lint
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
run: mise run cli:format
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run tsc
|
||||
run: pnpm check
|
||||
run: mise run cli:check
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run unit tests & coverage
|
||||
run: pnpm test
|
||||
run: mise run cli:test
|
||||
if: ${{ !cancelled() }}
|
||||
cli-unit-tests-win:
|
||||
name: Unit Test CLI (Windows)
|
||||
@@ -149,25 +136,18 @@ jobs:
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
run: mise run sdk:install && mise run sdk:build
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: mise run cli:install
|
||||
# Skip linter & formatter in Windows test.
|
||||
- name: Run tsc
|
||||
run: pnpm check
|
||||
run: mise run cli:check
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run unit tests & coverage
|
||||
run: pnpm test
|
||||
run: mise run cli:test
|
||||
if: ${{ !cancelled() }}
|
||||
web-lint:
|
||||
name: Lint Web
|
||||
@@ -176,35 +156,25 @@ jobs:
|
||||
runs-on: mich
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
- name: Run pnpm install
|
||||
run: pnpm rebuild && pnpm install --frozen-lockfile
|
||||
run: mise run sdk:install && mise run sdk:build
|
||||
- name: Run install
|
||||
run: mise run web:install
|
||||
- name: Run linter
|
||||
run: pnpm lint:p
|
||||
run: mise run web:lint-p
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
run: mise run web:format
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run svelte checks
|
||||
run: pnpm check:svelte
|
||||
run: mise run web:check-svelte
|
||||
if: ${{ !cancelled() }}
|
||||
web-unit-tests:
|
||||
name: Test Web
|
||||
@@ -213,32 +183,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
run: mise run sdk:install && mise run sdk:build
|
||||
- name: Run npm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: mise run web:install
|
||||
- name: Run tsc
|
||||
run: pnpm check:typescript
|
||||
run: mise run web:check
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run unit tests & coverage
|
||||
run: pnpm test
|
||||
run: mise run web:test
|
||||
if: ${{ !cancelled() }}
|
||||
i18n-tests:
|
||||
name: Test i18n
|
||||
@@ -252,18 +212,12 @@ jobs:
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Install dependencies
|
||||
run: pnpm --filter=immich-web install --frozen-lockfile
|
||||
run: mise run web:install
|
||||
- name: Format
|
||||
run: pnpm --filter=immich-web format:i18n
|
||||
run: mise run i18n:format-fix
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-files
|
||||
@@ -293,29 +247,22 @@ jobs:
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
run: mise run sdk:install && mise run sdk:build
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: mise run e2e:install
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
run: mise run e2e:lint
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
run: mise run e2e:format
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run tsc
|
||||
run: pnpm check
|
||||
run: mise run e2e:check
|
||||
if: ${{ !cancelled() }}
|
||||
server-medium-tests:
|
||||
name: Medium Tests (Server)
|
||||
@@ -324,26 +271,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run pnpm install
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Run install
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true mise run server:install
|
||||
- name: Run medium tests
|
||||
run: pnpm test:medium
|
||||
run: mise run server:test-medium
|
||||
if: ${{ !cancelled() }}
|
||||
e2e-tests-server-cli:
|
||||
name: End-to-End Tests (Server & CLI)
|
||||
@@ -352,9 +290,6 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
@@ -364,34 +299,25 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
run: mise run sdk:install && mise run sdk:build
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run setup web
|
||||
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
|
||||
working-directory: ./web
|
||||
run: mise run web:install && mise run web:svelte-kit-sync
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run setup cli
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./cli
|
||||
run: mise run cli:install && mise run cli:build
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: mise run e2e:install
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Docker build
|
||||
run: docker compose build
|
||||
run: docker compose -f e2e/docker-compose.yml build
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (api & cli)
|
||||
run: pnpm test
|
||||
run: mise run e2e:test
|
||||
if: ${{ !cancelled() }}
|
||||
e2e-tests-web:
|
||||
name: End-to-End Tests (Web)
|
||||
@@ -400,9 +326,6 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
@@ -412,29 +335,26 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
run: mise run sdk:install && mise run sdk:build
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run setup web
|
||||
run: mise run web:install && mise run web:svelte-kit-sync
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run setup cli
|
||||
run: mise run cli:install && mise run cli:build
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --only-shell
|
||||
working-directory: e2e
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Docker build
|
||||
run: docker compose build
|
||||
run: docker compose -f e2e/docker-compose.yml build
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (web)
|
||||
run: npx playwright test
|
||||
run: mise run e2e:test-web
|
||||
if: ${{ !cancelled() }}
|
||||
success-check-e2e:
|
||||
name: End-to-End Tests Success
|
||||
@@ -519,18 +439,12 @@ jobs:
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './.github/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Run install
|
||||
run: mise run github:install
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
run: mise run github:format
|
||||
if: ${{ !cancelled() }}
|
||||
shellcheck:
|
||||
name: ShellCheck
|
||||
@@ -556,18 +470,12 @@ jobs:
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true mise run server:install
|
||||
- name: Build the app
|
||||
run: pnpm --filter immich build
|
||||
run: mise run server:build
|
||||
- name: Run API generation
|
||||
run: ./bin/generate-open-api.sh
|
||||
working-directory: open-api
|
||||
@@ -611,25 +519,19 @@ jobs:
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup mise
|
||||
uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
run: mise run server:build
|
||||
- name: Run existing migrations
|
||||
run: pnpm migrations:run
|
||||
run: mise run server:migrations run
|
||||
- name: Test npm run schema:reset command works
|
||||
run: pnpm schema:reset
|
||||
run: mise run server:schema-reset
|
||||
- name: Generate new migrations
|
||||
continue-on-error: true
|
||||
run: pnpm migrations:generate src/TestMigration
|
||||
run: mise run server:migrations generate src/TestMigration
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-files
|
||||
@@ -646,7 +548,7 @@ jobs:
|
||||
cat ./src/*-TestMigration.ts
|
||||
exit 1
|
||||
- name: Run SQL generation
|
||||
run: pnpm sync:sql
|
||||
run: mise run server:sql
|
||||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/immich
|
||||
- name: Find file changes
|
||||
|
||||
14
Makefile
14
Makefile
@@ -10,14 +10,14 @@ dev-update: prepare-volumes
|
||||
dev-scale: prepare-volumes
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
|
||||
dev-docs: prepare-volumes
|
||||
dev-docs:
|
||||
npm --prefix docs run start
|
||||
|
||||
.PHONY: e2e
|
||||
e2e: prepare-volumes
|
||||
e2e:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||
|
||||
e2e-update: prepare-volumes
|
||||
e2e-update:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||
|
||||
e2e-down:
|
||||
@@ -73,6 +73,8 @@ define safe_chown
|
||||
if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \
|
||||
true; \
|
||||
else \
|
||||
STATUS=$$?; echo "Exit code: $$STATUS $(1)"; \
|
||||
echo "$$STATUS $(1)"; \
|
||||
echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \
|
||||
exit 1; \
|
||||
fi;
|
||||
@@ -83,11 +85,13 @@ prepare-volumes:
|
||||
@$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R))
|
||||
ifneq ($(UPLOAD_LOCATION),)
|
||||
ifeq ($(filter /%,$(UPLOAD_LOCATION)),)
|
||||
@mkdir -p "docker/$(UPLOAD_LOCATION)"
|
||||
@mkdir -p "docker/$(UPLOAD_LOCATION)/photos/upload"
|
||||
@$(call safe_chown,docker/$(UPLOAD_LOCATION),)
|
||||
@$(call safe_chown,docker/$(UPLOAD_LOCATION)/photos,-R)
|
||||
else
|
||||
@mkdir -p "$(UPLOAD_LOCATION)"
|
||||
@mkdir -p "$(UPLOAD_LOCATION)/photos/upload"
|
||||
@$(call safe_chown,$(UPLOAD_LOCATION),)
|
||||
@$(call safe_chown,$(UPLOAD_LOCATION)/photos,-R)
|
||||
endif
|
||||
endif
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
22.18.0
|
||||
@@ -42,17 +42,6 @@
|
||||
"vitest-fetch-mock": "^0.4.0",
|
||||
"yaml": "^2.3.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"prepack": "npm run build",
|
||||
"test": "vitest",
|
||||
"test:cov": "vitest --coverage",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/immich-app/immich.git",
|
||||
@@ -67,8 +56,5 @@
|
||||
"fastq": "^1.17.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
22.18.0
|
||||
@@ -5,7 +5,7 @@ After making any changes in the `server/src/schema`, a database migration need t
|
||||
1. Run the command
|
||||
|
||||
```bash
|
||||
pnpm run migrations:generate <migration-name>
|
||||
mise run server:migrations generate <migration-name>
|
||||
```
|
||||
|
||||
2. Check if the migration file makes sense.
|
||||
|
||||
@@ -8,11 +8,11 @@ When contributing code through a pull request, please check the following:
|
||||
|
||||
## Web Checks
|
||||
|
||||
- [ ] `pnpm run lint` (linting via ESLint)
|
||||
- [ ] `pnpm run format` (formatting via Prettier)
|
||||
- [ ] `pnpm run check:svelte` (Type checking via SvelteKit)
|
||||
- [ ] `pnpm run check:typescript` (check typescript)
|
||||
- [ ] `pnpm test` (unit tests)
|
||||
- [ ] `mise run web:lint` (linting via ESLint)
|
||||
- [ ] `mise run web:format` (formatting via Prettier)
|
||||
- [ ] `mise run web:check` (check typescript)
|
||||
- [ ] `mise run web:check-svelte` (Type checking via SvelteKit)
|
||||
- [ ] `mise run web:test` (unit tests)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -25,17 +25,18 @@ Run all web checks with `pnpm run check:all`
|
||||
|
||||
## Server Checks
|
||||
|
||||
- [ ] `pnpm run lint` (linting via ESLint)
|
||||
- [ ] `pnpm run format` (formatting via Prettier)
|
||||
- [ ] `pnpm run check` (Type checking via `tsc`)
|
||||
- [ ] `pnpm test` (unit tests)
|
||||
- [ ] `mise run server:lint` (linting via ESLint)
|
||||
- [ ] `mise run server:format` (formatting via Prettier)
|
||||
- [ ] `mise run server:check` (type checking via `tsc`)
|
||||
- [ ] `mise run server:test` (unit tests)
|
||||
- [ ] `mise run server:test-medium` (medium tests)
|
||||
|
||||
:::tip AIO
|
||||
Run all server checks with `pnpm run check:all`
|
||||
:::
|
||||
|
||||
:::info Auto Fix
|
||||
You can use `pnpm run __:fix` to potentially correct some issues automatically for `pnpm run format` and `lint`.
|
||||
You can use `mise run server:lint-fix` and `mise run server:format-fix` to potentially correct some issues automatically.
|
||||
:::
|
||||
|
||||
## Mobile Checks
|
||||
|
||||
@@ -2,20 +2,6 @@
|
||||
"name": "documentation",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"start": "docusaurus start --port 3005",
|
||||
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||
"build": "npm run copy:openapi && docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "~3.8.0",
|
||||
"@docusaurus/preset-classic": "~3.8.0",
|
||||
@@ -58,8 +44,5 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
22.18.0
|
||||
@@ -4,17 +4,6 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"test:web": "npx playwright test",
|
||||
"start:web": "npx playwright test --ui",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
@@ -52,8 +41,5 @@
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1557,6 +1557,7 @@
|
||||
"purchase_server_description_2": "Supporter status",
|
||||
"purchase_server_title": "Server",
|
||||
"purchase_settings_server_activated": "The server product key is managed by the admin",
|
||||
"query_asset_id": "Query Asset ID",
|
||||
"queue_status": "Queuing {count}/{total}",
|
||||
"rating": "Star rating",
|
||||
"rating_clear": "Clear rating",
|
||||
@@ -1735,7 +1736,7 @@
|
||||
"select_user_for_sharing_page_err_album": "Failed to create album",
|
||||
"selected": "Selected",
|
||||
"selected_count": "{count, plural, other {# selected}}",
|
||||
"selected_gps_coordinates": "selected gps coordinates",
|
||||
"selected_gps_coordinates": "Selected GPS Coordinates",
|
||||
"send_message": "Send message",
|
||||
"send_welcome_email": "Send welcome email",
|
||||
"server_endpoint": "Server Endpoint",
|
||||
@@ -2077,6 +2078,7 @@
|
||||
"view_next_asset": "View next asset",
|
||||
"view_previous_asset": "View previous asset",
|
||||
"view_qr_code": "View QR code",
|
||||
"view_similar_photos": "View similar photos",
|
||||
"view_stack": "View Stack",
|
||||
"view_user": "View User",
|
||||
"viewer_remove_from_stack": "Remove from Stack",
|
||||
|
||||
34
mise.lock
Normal file
34
mise.lock
Normal file
@@ -0,0 +1,34 @@
|
||||
[tools.dart]
|
||||
version = "3.8.2"
|
||||
backend = "asdf:dart"
|
||||
|
||||
[tools.flutter]
|
||||
version = "3.32.8-stable"
|
||||
backend = "asdf:flutter"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.31.4"
|
||||
backend = "github:CQLabs/homebrew-dcm"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm".platforms.linux-x64]
|
||||
checksum = "blake3:e9df5b765df327e1248fccf2c6165a89d632a065667f99c01765bf3047b94955"
|
||||
size = 8821083
|
||||
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.31.4/dcm-linux-x64-release.zip"
|
||||
|
||||
[tools.node]
|
||||
version = "22.18.0"
|
||||
backend = "core:node"
|
||||
|
||||
[tools.node.platforms.linux-x64]
|
||||
checksum = "sha256:a2e703725d8683be86bb5da967bf8272f4518bdaf10f21389e2b2c9eaeae8c8a"
|
||||
size = 54824343
|
||||
url = "https://nodejs.org/dist/v22.18.0/node-v22.18.0-linux-x64.tar.gz"
|
||||
|
||||
[tools.pnpm]
|
||||
version = "10.14.0"
|
||||
backend = "aqua:pnpm/pnpm"
|
||||
|
||||
[tools.pnpm.platforms.linux-x64]
|
||||
checksum = "blake3:13dfa46b7173d3cad3bad60a756a492ecf0bce48b23eb9f793e7ccec5a09b46d"
|
||||
size = 66231525
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v10.14.0/pnpm-linux-x64"
|
||||
312
mise.toml
Normal file
312
mise.toml
Normal file
@@ -0,0 +1,312 @@
|
||||
[tools]
|
||||
node = "22.18.0"
|
||||
flutter = "3.32.8"
|
||||
pnpm = "10.14.0"
|
||||
dart = "3.8.2"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.31.4"
|
||||
bin = "dcm"
|
||||
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
||||
|
||||
[settings]
|
||||
experimental = true
|
||||
lockfile = true
|
||||
pin = true
|
||||
|
||||
# .github
|
||||
[tasks."github:install"]
|
||||
run = "pnpm install --filter github --frozen-lockfile"
|
||||
|
||||
[tasks."github:format"]
|
||||
env._.path = "./.github/node_modules/.bin"
|
||||
dir = ".github"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."github:format-fix"]
|
||||
env._.path = "./.github/node_modules/.bin"
|
||||
dir = ".github"
|
||||
run = "prettier --write ."
|
||||
|
||||
# @immich/cli
|
||||
[tasks."cli:install"]
|
||||
run = "pnpm install --filter @immich/cli --frozen-lockfile"
|
||||
|
||||
[tasks."cli:build"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "vite build"
|
||||
|
||||
[tasks."cli:test"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "vite"
|
||||
|
||||
[tasks."cli:lint"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||
|
||||
[tasks."cli:lint-fix"]
|
||||
run = "mise run cli:lint --fix"
|
||||
|
||||
[tasks."cli:format"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."cli:format-fix"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks."cli:check"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "tsc --noEmit"
|
||||
|
||||
# @immich/sdk
|
||||
[tasks."sdk:install"]
|
||||
run = "pnpm install --filter @immich/sdk --frozen-lockfile"
|
||||
|
||||
[tasks."sdk:build"]
|
||||
env._.path = "./open-api/typescript-sdk/node_modules/.bin"
|
||||
dir = "./open-api/typescript-sdk"
|
||||
run = "tsc"
|
||||
|
||||
# docs
|
||||
[tasks."docs:install"]
|
||||
run = "pnpm install --filter documentation --frozen-lockfile"
|
||||
|
||||
[tasks."docs:start"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = "docusaurus --port 3005"
|
||||
|
||||
[tasks."docs:build"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = [
|
||||
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||
"docusaurus build",
|
||||
]
|
||||
|
||||
|
||||
[tasks."docs:preview"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = "docusaurus serve"
|
||||
|
||||
|
||||
[tasks."docs:format"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."docs:format-fix"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = "prettier --write ."
|
||||
|
||||
|
||||
# e2e
|
||||
[tasks."e2e:install"]
|
||||
run = "pnpm install --filter immich-e2e --frozen-lockfile"
|
||||
|
||||
[tasks."e2e:test"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "vitest --run"
|
||||
|
||||
[tasks."e2e:test-web"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "playwright test"
|
||||
|
||||
[tasks."e2e:format"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."e2e:format-fix"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks."e2e:lint"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||
|
||||
[tasks."e2e:lint-fix"]
|
||||
run = "mise run e2e:lint --fix"
|
||||
|
||||
[tasks."e2e:check"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "tsc --noEmit"
|
||||
|
||||
# i18n
|
||||
[tasks."i18n:format"]
|
||||
run = "mise run i18n:format-fix"
|
||||
|
||||
[tasks."i18n:format-fix"]
|
||||
run = "pnpm dlx sort-json ./i18n/*.json"
|
||||
|
||||
|
||||
# server
|
||||
[tasks."server:install"]
|
||||
run = "pnpm install --filter immich --frozen-lockfile"
|
||||
|
||||
[tasks."server:build"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "nest build"
|
||||
|
||||
[tasks."server:test"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "vitest --config test/vitest.config.mjs"
|
||||
|
||||
[tasks."server:test-medium"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "vitest --config test/vitest.config.medium.mjs"
|
||||
|
||||
[tasks."server:format"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."server:format-fix"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks."server:lint"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0"
|
||||
|
||||
[tasks."server:lint-fix"]
|
||||
run = "mise run server:lint --fix"
|
||||
|
||||
[tasks."server:check"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "tsc --noEmit"
|
||||
|
||||
[tasks."server:sql"]
|
||||
dir = "server"
|
||||
run = "node ./dist/bin/sync-open-api.js"
|
||||
|
||||
[tasks."server:open-api"]
|
||||
dir = "server"
|
||||
run = "node ./dist/bin/sync-open-api.js"
|
||||
|
||||
[tasks."server:migrations"]
|
||||
dir = "server"
|
||||
run = "node ./dist/bin/migrations.js"
|
||||
description = "Run database migration commands (create, generate, run, debug, or query)"
|
||||
|
||||
[tasks."server:schema-drop"]
|
||||
run = "mise run server:migrations query 'DROP schema public cascade; CREATE schema public;'"
|
||||
|
||||
[tasks."server:schema-reset"]
|
||||
run = "mise run server:schema-drop && mise run server:migrations run"
|
||||
|
||||
[tasks."server:email-dev"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "email dev -p 3050 --dir src/emails"
|
||||
|
||||
[tasks."server:checklist"]
|
||||
run = [
|
||||
"mise run server:install",
|
||||
"mise run server:format",
|
||||
"mise run server:lint",
|
||||
"mise run server:check",
|
||||
"mise run server:test-medium --run",
|
||||
"mise run server:test --run",
|
||||
]
|
||||
|
||||
|
||||
# web
|
||||
[tasks."web:install"]
|
||||
run = "pnpm install --filter immich-web --frozen-lockfile"
|
||||
|
||||
[tasks."web:svelte-kit-sync"]
|
||||
env._.path = "./web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "svelte-kit sync"
|
||||
|
||||
[tasks."web:build"]
|
||||
env._.path = "./web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vite build"
|
||||
|
||||
[tasks."web:build-stats"]
|
||||
env.BUILD_STATS = "true"
|
||||
env._.path = "./web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vite build"
|
||||
|
||||
[tasks."web:preview"]
|
||||
env._.path = "./web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vite preview"
|
||||
|
||||
[tasks."web:start"]
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vite dev --host 0.0.0.0 --port 3000"
|
||||
|
||||
[tasks."web:test"]
|
||||
depends = "web:svelte-kit-sync"
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vitest"
|
||||
|
||||
[tasks."web:format"]
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."web:format-fix"]
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks."web:lint"]
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "eslint . --max-warnings 0"
|
||||
|
||||
[tasks."web:lint-p"]
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "eslint-p . --max-warnings 0 --concurrency=4"
|
||||
|
||||
[tasks."web:lint-fix"]
|
||||
run = "mise run web:lint --fix"
|
||||
|
||||
[tasks."web:check"]
|
||||
depends = "web:svelte-kit-sync"
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "tsc --noEmit"
|
||||
|
||||
[tasks."web:check-svelte"]
|
||||
depends = "web:svelte-kit-sync"
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte"
|
||||
|
||||
[tasks."web:checklist"]
|
||||
run = [
|
||||
"mise run web:install",
|
||||
"mise run web:format",
|
||||
"mise run web:check",
|
||||
"mise run web:test --run",
|
||||
"mise run web:lint",
|
||||
]
|
||||
@@ -61,9 +61,8 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface BackgroundWorkerFgHostApi {
|
||||
fun enableSyncWorker()
|
||||
fun enableUploadWorker(callbackHandle: Long)
|
||||
fun disableUploadWorker()
|
||||
fun enable()
|
||||
fun disable()
|
||||
|
||||
companion object {
|
||||
/** The codec used by BackgroundWorkerFgHostApi. */
|
||||
@@ -75,11 +74,11 @@ interface BackgroundWorkerFgHostApi {
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$separatedMessageChannelSuffix", codec)
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.enableSyncWorker()
|
||||
api.enable()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
@@ -91,29 +90,11 @@ interface BackgroundWorkerFgHostApi {
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val callbackHandleArg = args[0] as Long
|
||||
val wrapped: List<Any?> = try {
|
||||
api.enableUploadWorker(callbackHandleArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$separatedMessageChannelSuffix", codec)
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.disableUploadWorker()
|
||||
api.disable()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
@@ -130,6 +111,7 @@ interface BackgroundWorkerFgHostApi {
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface BackgroundWorkerBgHostApi {
|
||||
fun onInitialized()
|
||||
fun close()
|
||||
|
||||
companion object {
|
||||
/** The codec used by BackgroundWorkerBgHostApi. */
|
||||
@@ -156,6 +138,22 @@ interface BackgroundWorkerBgHostApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.close()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,23 +165,6 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p
|
||||
BackgroundWorkerPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onLocalSync(maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(maxSecondsArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
} else {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
|
||||
@@ -11,17 +11,11 @@ import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor.DartCallback
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
|
||||
private const val TAG = "BackgroundWorker"
|
||||
|
||||
enum class BackgroundTaskType {
|
||||
LOCAL_SYNC,
|
||||
UPLOAD,
|
||||
}
|
||||
|
||||
class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
ListenableWorker(context, params), BackgroundWorkerBgHostApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
@@ -58,25 +52,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||
engine = FlutterEngine(ctx)
|
||||
|
||||
// Retrieve the callback handle stored by the main Flutter app
|
||||
// This handle points to the Flutter function that should be executed in the background
|
||||
val callbackHandle =
|
||||
ctx.getSharedPreferences(BackgroundWorkerApiImpl.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getLong(BackgroundWorkerApiImpl.SHARED_PREF_CALLBACK_HANDLE, 0L)
|
||||
|
||||
if (callbackHandle == 0L) {
|
||||
// Without a valid callback handle, we cannot start the Flutter background execution
|
||||
complete(Result.failure())
|
||||
return@ensureInitializationCompleteAsync
|
||||
}
|
||||
|
||||
// Start the Flutter engine with the specified callback as the entry point
|
||||
val callback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
|
||||
if (callback == null) {
|
||||
complete(Result.failure())
|
||||
return@ensureInitializationCompleteAsync
|
||||
}
|
||||
|
||||
// Register custom plugins
|
||||
MainActivity.registerPlugins(ctx, engine!!)
|
||||
flutterApi =
|
||||
@@ -86,8 +61,12 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
api = this
|
||||
)
|
||||
|
||||
engine!!.dartExecutor.executeDartCallback(
|
||||
DartCallback(ctx.assets, loader.findAppBundlePath(), callback)
|
||||
engine!!.dartExecutor.executeDartEntrypoint(
|
||||
DartExecutor.DartEntrypoint(
|
||||
loader.findAppBundlePath(),
|
||||
"package:immich_mobile/domain/services/background_worker.service.dart",
|
||||
"backgroundSyncNativeEntrypoint"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,23 +79,10 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
* This method acts as a bridge between the native Android background task system and Flutter.
|
||||
*/
|
||||
override fun onInitialized() {
|
||||
val taskTypeIndex = inputData.getInt(BackgroundWorkerApiImpl.WORKER_DATA_TASK_TYPE, 0)
|
||||
val taskType = BackgroundTaskType.entries[taskTypeIndex]
|
||||
|
||||
when (taskType) {
|
||||
BackgroundTaskType.LOCAL_SYNC -> flutterApi?.onLocalSync(null) { handleHostResult(it) }
|
||||
BackgroundTaskType.UPLOAD -> flutterApi?.onAndroidUpload { handleHostResult(it) }
|
||||
}
|
||||
flutterApi?.onAndroidUpload { handleHostResult(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the system has to stop this worker because constraints are
|
||||
* no longer met or the system needs resources for more important tasks
|
||||
* This is also called when the worker has been explicitly cancelled or replaced
|
||||
*/
|
||||
override fun onStopped() {
|
||||
Log.d(TAG, "About to stop BackupWorker")
|
||||
|
||||
override fun close() {
|
||||
if (isComplete) {
|
||||
return
|
||||
}
|
||||
@@ -134,6 +100,16 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the system has to stop this worker because constraints are
|
||||
* no longer met or the system needs resources for more important tasks
|
||||
* This is also called when the worker has been explicitly cancelled or replaced
|
||||
*/
|
||||
override fun onStopped() {
|
||||
Log.d(TAG, "About to stop BackupWorker")
|
||||
close()
|
||||
}
|
||||
|
||||
private fun handleHostResult(result: kotlin.Result<Unit>) {
|
||||
if (isComplete) {
|
||||
return
|
||||
@@ -154,8 +130,10 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
* - Parameter success: Indicates whether the background task completed successfully
|
||||
*/
|
||||
private fun complete(success: Result) {
|
||||
Log.d(TAG, "About to complete BackupWorker with result: $success")
|
||||
isComplete = true
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
flutterApi = null
|
||||
completionHandler.set(success)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@ package app.alextran.immich.background
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
@@ -16,19 +14,13 @@ private const val TAG = "BackgroundUploadImpl"
|
||||
|
||||
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
override fun enableSyncWorker() {
|
||||
|
||||
override fun enable() {
|
||||
enqueueMediaObserver(ctx)
|
||||
Log.i(TAG, "Scheduled media observer")
|
||||
}
|
||||
|
||||
override fun enableUploadWorker(callbackHandle: Long) {
|
||||
updateUploadEnabled(ctx, true)
|
||||
updateCallbackHandle(ctx, callbackHandle)
|
||||
Log.i(TAG, "Scheduled background upload tasks")
|
||||
}
|
||||
|
||||
override fun disableUploadWorker() {
|
||||
updateUploadEnabled(ctx, false)
|
||||
override fun disable() {
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
Log.i(TAG, "Cancelled background upload tasks")
|
||||
}
|
||||
@@ -37,32 +29,14 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||
|
||||
const val WORKER_DATA_TASK_TYPE = "taskType"
|
||||
|
||||
const val SHARED_PREF_NAME = "Immich::Background"
|
||||
const val SHARED_PREF_BACKUP_ENABLED = "Background::backup::enabled"
|
||||
const val SHARED_PREF_CALLBACK_HANDLE = "Background::backup::callbackHandle"
|
||||
|
||||
private fun updateUploadEnabled(context: Context, enabled: Boolean) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
|
||||
putBoolean(SHARED_PREF_BACKUP_ENABLED, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCallbackHandle(context: Context, callbackHandle: Long) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
|
||||
putLong(SHARED_PREF_CALLBACK_HANDLE, callbackHandle)
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueueMediaObserver(ctx: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
||||
.setTriggerContentUpdateDelay(5, TimeUnit.SECONDS)
|
||||
.setTriggerContentMaxDelay(1, TimeUnit.MINUTES)
|
||||
.setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
|
||||
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
|
||||
@@ -74,15 +48,13 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
|
||||
}
|
||||
|
||||
fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) {
|
||||
fun enqueueBackgroundWorker(ctx: Context) {
|
||||
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
|
||||
|
||||
val data = Data.Builder()
|
||||
data.putInt(WORKER_DATA_TASK_TYPE, taskType.ordinal)
|
||||
val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.setInputData(data.build()).build()
|
||||
.build()
|
||||
WorkManager.getInstance(ctx)
|
||||
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||
|
||||
|
||||
@@ -6,29 +6,17 @@ import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
override fun doWork(): Result {
|
||||
Log.i("MediaObserver", "Content change detected, starting background worker")
|
||||
override fun doWork(): Result {
|
||||
Log.i("MediaObserver", "Content change detected, starting background worker")
|
||||
// Re-enqueue itself to listen for future changes
|
||||
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
|
||||
|
||||
// Enqueue backup worker only if there are new media changes
|
||||
if (triggeredContentUris.isNotEmpty()) {
|
||||
val type =
|
||||
if (isBackupEnabled(ctx)) BackgroundTaskType.UPLOAD else BackgroundTaskType.LOCAL_SYNC
|
||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx, type)
|
||||
}
|
||||
|
||||
// Re-enqueue itself to listen for future changes
|
||||
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun isBackupEnabled(context: Context): Boolean {
|
||||
val prefs =
|
||||
context.getSharedPreferences(
|
||||
BackgroundWorkerApiImpl.SHARED_PREF_NAME,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
return prefs.getBoolean(BackgroundWorkerApiImpl.SHARED_PREF_BACKUP_ENABLED, false)
|
||||
// Enqueue backup worker only if there are new media changes
|
||||
if (triggeredContentUris.isNotEmpty()) {
|
||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- cupertino_http (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
@@ -80,8 +77,6 @@ PODS:
|
||||
- Flutter
|
||||
- network_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- objective_c (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -141,7 +136,6 @@ DEPENDENCIES:
|
||||
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
@@ -160,7 +154,6 @@ DEPENDENCIES:
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
|
||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
@@ -191,8 +184,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
cupertino_http:
|
||||
:path: ".symlinks/plugins/cupertino_http/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
file_picker:
|
||||
@@ -229,8 +220,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/native_video_player/ios"
|
||||
network_info_plus:
|
||||
:path: ".symlinks/plugins/network_info_plus/ios"
|
||||
objective_c:
|
||||
:path: ".symlinks/plugins/objective_c/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -260,7 +249,6 @@ SPEC CHECKSUMS:
|
||||
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
|
||||
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
@@ -282,7 +270,6 @@ SPEC CHECKSUMS:
|
||||
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
|
||||
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
|
||||
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -507,14 +507,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -543,14 +539,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
|
||||
@@ -24,7 +24,7 @@ import UIKit
|
||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||
|
||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||
BackgroundWorkerApiImpl.registerBackgroundProcessing()
|
||||
BackgroundWorkerApiImpl.registerBackgroundWorkers()
|
||||
|
||||
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
||||
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
|
||||
|
||||
@@ -73,9 +73,8 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol BackgroundWorkerFgHostApi {
|
||||
func enableSyncWorker() throws
|
||||
func enableUploadWorker(callbackHandle: Int64) throws
|
||||
func disableUploadWorker() throws
|
||||
func enable() throws
|
||||
func disable() throws
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -84,52 +83,38 @@ class BackgroundWorkerFgHostApiSetup {
|
||||
/// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let enableSyncWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
enableSyncWorkerChannel.setMessageHandler { _, reply in
|
||||
enableChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.enableSyncWorker()
|
||||
try api.enable()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enableSyncWorkerChannel.setMessageHandler(nil)
|
||||
enableChannel.setMessageHandler(nil)
|
||||
}
|
||||
let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
enableUploadWorkerChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let callbackHandleArg = args[0] as! Int64
|
||||
disableChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.enableUploadWorker(callbackHandle: callbackHandleArg)
|
||||
try api.disable()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enableUploadWorkerChannel.setMessageHandler(nil)
|
||||
}
|
||||
let disableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
disableUploadWorkerChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.disableUploadWorker()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disableUploadWorkerChannel.setMessageHandler(nil)
|
||||
disableChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol BackgroundWorkerBgHostApi {
|
||||
func onInitialized() throws
|
||||
func close() throws
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -151,11 +136,23 @@ class BackgroundWorkerBgHostApiSetup {
|
||||
} else {
|
||||
onInitializedChannel.setMessageHandler(nil)
|
||||
}
|
||||
let closeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
closeChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.close()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
closeChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
|
||||
protocol BackgroundWorkerFlutterApiProtocol {
|
||||
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
@@ -170,24 +167,6 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
|
||||
var codec: BackgroundWorkerPigeonCodec {
|
||||
return BackgroundWorkerPigeonCodec.shared
|
||||
}
|
||||
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync\(messageChannelSuffix)"
|
||||
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||
channel.sendMessage([maxSecondsArg] as [Any?]) { response in
|
||||
guard let listResponse = response as? [Any?] else {
|
||||
completion(.failure(createConnectionError(withChannelName: channelName)))
|
||||
return
|
||||
}
|
||||
if listResponse.count > 1 {
|
||||
let code: String = listResponse[0] as! String
|
||||
let message: String? = nilOrValue(listResponse[1])
|
||||
let details: String? = nilOrValue(listResponse[2])
|
||||
completion(.failure(PigeonError(code: code, message: message, details: details)))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)"
|
||||
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BackgroundTasks
|
||||
import Flutter
|
||||
|
||||
enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
|
||||
enum BackgroundTaskType { case refresh, processing }
|
||||
|
||||
/*
|
||||
* DEBUG: Testing Background Tasks in Xcode
|
||||
@@ -9,10 +9,6 @@ enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
|
||||
* To test background task functionality during development:
|
||||
* 1. Pause the application in Xcode debugger
|
||||
* 2. In the debugger console, enter one of the following commands:
|
||||
|
||||
## For local sync (short-running sync):
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
|
||||
|
||||
## For background refresh (short-running sync):
|
||||
|
||||
@@ -24,8 +20,6 @@ enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
|
||||
|
||||
* To simulate task expiration (useful for testing expiration handlers):
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
|
||||
@@ -86,28 +80,10 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
* starts the engine, and sets up a timeout timer if specified.
|
||||
*/
|
||||
func run() {
|
||||
// Retrieve the callback handle stored by the main Flutter app
|
||||
// This handle points to the Flutter function that should be executed in the background
|
||||
let callbackHandle = Int64(UserDefaults.standard.string(
|
||||
forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) ?? "0") ?? 0
|
||||
|
||||
if callbackHandle == 0 {
|
||||
// Without a valid callback handle, we cannot start the Flutter background execution
|
||||
complete(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the callback handle to retrieve the actual Flutter callback information
|
||||
guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else {
|
||||
// The callback handle is invalid or the callback was not found
|
||||
complete(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
// Start the Flutter engine with the specified callback as the entry point
|
||||
let isRunning = engine.run(
|
||||
withEntrypoint: callback.callbackName,
|
||||
libraryURI: callback.callbackLibraryPath
|
||||
withEntrypoint: "backgroundSyncNativeEntrypoint",
|
||||
libraryURI: "package:immich_mobile/domain/services/background_worker.service.dart"
|
||||
)
|
||||
|
||||
// Verify that the Flutter engine started successfully
|
||||
@@ -127,7 +103,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
if maxSeconds != nil {
|
||||
// Schedule a timer to cancel the task after the specified timeout period
|
||||
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
|
||||
self.cancel()
|
||||
self.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,25 +114,17 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
* This method acts as a bridge between the native iOS background task system and Flutter.
|
||||
*/
|
||||
func onInitialized() throws {
|
||||
switch self.taskType {
|
||||
case .refreshUpload, .processingUpload:
|
||||
flutterApi?.onIosUpload(isRefresh: self.taskType == .refreshUpload,
|
||||
maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
|
||||
self.handleHostResult(result: result)
|
||||
})
|
||||
case .localSync:
|
||||
flutterApi?.onLocalSync(maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
|
||||
self.handleHostResult(result: result)
|
||||
})
|
||||
}
|
||||
flutterApi?.onIosUpload(isRefresh: self.taskType == .refresh, maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
|
||||
self.handleHostResult(result: result)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Cancels the currently running background task, either due to timeout or external request.
|
||||
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
|
||||
* the completion handler is eventually called even if Flutter doesn't respond.
|
||||
*/
|
||||
func cancel() {
|
||||
func close() {
|
||||
if isComplete {
|
||||
return
|
||||
}
|
||||
@@ -172,6 +140,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
self.complete(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles the result from Flutter API calls and determines the success/failure status.
|
||||
@@ -182,7 +151,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
private func handleHostResult(result: Result<Void, PigeonError>) {
|
||||
switch result {
|
||||
case .success(): self.complete(success: true)
|
||||
case .failure(_): self.cancel()
|
||||
case .failure(_): self.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +164,10 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
* - Parameter success: Indicates whether the background task completed successfully
|
||||
*/
|
||||
private func complete(success: Bool) {
|
||||
if(isComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
isComplete = true
|
||||
engine.destroyContext()
|
||||
completionHandler(success)
|
||||
|
||||
@@ -1,84 +1,40 @@
|
||||
import BackgroundTasks
|
||||
|
||||
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
func enableSyncWorker() throws {
|
||||
BackgroundWorkerApiImpl.scheduleLocalSync()
|
||||
print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled")
|
||||
|
||||
func enable() throws {
|
||||
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
||||
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
||||
print("BackgroundUploadImpl:enbale Background worker scheduled")
|
||||
}
|
||||
|
||||
func enableUploadWorker(callbackHandle: Int64) throws {
|
||||
BackgroundWorkerApiImpl.updateUploadEnabled(true)
|
||||
// Store the callback handle for later use when starting background Flutter isolates
|
||||
BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle)
|
||||
|
||||
BackgroundWorkerApiImpl.scheduleRefreshUpload()
|
||||
BackgroundWorkerApiImpl.scheduleProcessingUpload()
|
||||
print("BackgroundUploadImpl:enableUploadWorker Scheduled background upload tasks")
|
||||
func disable() throws {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
||||
print("BackgroundUploadImpl:disableUploadWorker Disabled background workers")
|
||||
}
|
||||
|
||||
func disableUploadWorker() throws {
|
||||
BackgroundWorkerApiImpl.updateUploadEnabled(false)
|
||||
BackgroundWorkerApiImpl.cancelUploadTasks()
|
||||
print("BackgroundUploadImpl:disableUploadWorker Disabled background upload tasks")
|
||||
}
|
||||
|
||||
public static let backgroundUploadEnabledKey = "immich:background:backup:enabled"
|
||||
public static let backgroundUploadCallbackHandleKey = "immich:background:backup:callbackHandle"
|
||||
|
||||
private static let localSyncTaskID = "app.alextran.immich.background.localSync"
|
||||
private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload"
|
||||
private static let processingUploadTaskID = "app.alextran.immich.background.processingUpload"
|
||||
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
||||
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
|
||||
|
||||
private static func updateUploadEnabled(_ isEnabled: Bool) {
|
||||
return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey)
|
||||
}
|
||||
|
||||
private static func updateUploadCallbackHandle(_ callbackHandle: Int64) {
|
||||
return UserDefaults.standard.set(String(callbackHandle), forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey)
|
||||
}
|
||||
|
||||
private static func cancelUploadTasks() {
|
||||
BackgroundWorkerApiImpl.updateUploadEnabled(false)
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID);
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID);
|
||||
}
|
||||
|
||||
public static func registerBackgroundProcessing() {
|
||||
public static func registerBackgroundWorkers() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in
|
||||
forTaskWithIdentifier: processingTaskID, using: nil) { task in
|
||||
if task is BGProcessingTask {
|
||||
handleBackgroundProcessing(task: task as! BGProcessingTask)
|
||||
}
|
||||
}
|
||||
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in
|
||||
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
|
||||
if task is BGAppRefreshTask {
|
||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload)
|
||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
|
||||
}
|
||||
}
|
||||
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: localSyncTaskID, using: nil) { task in
|
||||
if task is BGAppRefreshTask {
|
||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .localSync)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func scheduleLocalSync() {
|
||||
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: localSyncTaskID)
|
||||
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(backgroundRefresh)
|
||||
} catch {
|
||||
print("Could not schedule the local sync task \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func scheduleRefreshUpload() {
|
||||
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID)
|
||||
private static func scheduleRefreshWorker() {
|
||||
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshTaskID)
|
||||
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
|
||||
|
||||
do {
|
||||
@@ -88,8 +44,8 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
private static func scheduleProcessingUpload() {
|
||||
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID)
|
||||
private static func scheduleProcessingWorker() {
|
||||
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingTaskID)
|
||||
|
||||
backgroundProcessing.requiresNetworkConnectivity = true
|
||||
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
|
||||
@@ -101,16 +57,16 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) {
|
||||
scheduleRefreshUpload()
|
||||
// Restrict the refresh task to run only for a maximum of 20 seconds
|
||||
runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20)
|
||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
|
||||
scheduleRefreshWorker()
|
||||
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
|
||||
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
|
||||
}
|
||||
|
||||
private static func handleBackgroundProcessing(task: BGProcessingTask) {
|
||||
scheduleProcessingUpload()
|
||||
scheduleProcessingWorker()
|
||||
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
|
||||
runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil)
|
||||
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +90,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
|
||||
task.expirationHandler = {
|
||||
DispatchQueue.main.async {
|
||||
backgroundWorker.cancel()
|
||||
backgroundWorker.close()
|
||||
}
|
||||
isSuccess = false
|
||||
|
||||
|
||||
@@ -46,6 +46,23 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||
assetCache.countLimit = 10000
|
||||
return assetCache
|
||||
}()
|
||||
private static let activitySemaphore = DispatchSemaphore(value: 1)
|
||||
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
processingQueue.suspend()
|
||||
activitySemaphore.wait()
|
||||
}
|
||||
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
processingQueue.resume()
|
||||
activitySemaphore.signal()
|
||||
}
|
||||
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||
Self.processingQueue.async {
|
||||
@@ -53,6 +70,7 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||
|
||||
let (width, height, pointer) = thumbHashToRGBA(hash: data)
|
||||
self.waitForActiveState()
|
||||
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
|
||||
}
|
||||
}
|
||||
@@ -142,6 +160,7 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
self.waitForActiveState()
|
||||
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
|
||||
Self.removeRequest(requestId: requestId)
|
||||
}
|
||||
@@ -184,4 +203,9 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||
return asset
|
||||
}
|
||||
|
||||
func waitForActiveState() {
|
||||
Self.activitySemaphore.wait()
|
||||
Self.activitySemaphore.signal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,190 +1,189 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.alextran.immich.background.localSync</string>
|
||||
<string>app.alextran.immich.background.refreshUpload</string>
|
||||
<string>app.alextran.immich.background.processingUpload</string>
|
||||
<string>app.alextran.immich.backgroundFetch</string>
|
||||
<string>app.alextran.immich.backgroundProcessing</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true />
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ShareHandler</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.file-url</string>
|
||||
<string>public.image</string>
|
||||
<string>public.text</string>
|
||||
<string>public.movie</string>
|
||||
<string>public.url</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ar</string>
|
||||
<string>ca</string>
|
||||
<string>cs</string>
|
||||
<string>da</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fi</string>
|
||||
<string>fr</string>
|
||||
<string>he</string>
|
||||
<string>hi</string>
|
||||
<string>hu</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>lv</string>
|
||||
<string>mn</string>
|
||||
<string>nb</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt</string>
|
||||
<string>ro</string>
|
||||
<string>ru</string>
|
||||
<string>sk</string>
|
||||
<string>sl</string>
|
||||
<string>sr</string>
|
||||
<string>sv</string>
|
||||
<string>th</string>
|
||||
<string>uk</string>
|
||||
<string>vi</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.140.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Share Extension</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Deep Link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>immich</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>219</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false />
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true />
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true />
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>We need local network permission to connect to the local server using IP address and
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.alextran.immich.background.refreshUpload</string>
|
||||
<string>app.alextran.immich.background.processingUpload</string>
|
||||
<string>app.alextran.immich.backgroundFetch</string>
|
||||
<string>app.alextran.immich.backgroundProcessing</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ShareHandler</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.file-url</string>
|
||||
<string>public.image</string>
|
||||
<string>public.text</string>
|
||||
<string>public.movie</string>
|
||||
<string>public.url</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ar</string>
|
||||
<string>ca</string>
|
||||
<string>cs</string>
|
||||
<string>da</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fi</string>
|
||||
<string>fr</string>
|
||||
<string>he</string>
|
||||
<string>hi</string>
|
||||
<string>hu</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>lv</string>
|
||||
<string>mn</string>
|
||||
<string>nb</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt</string>
|
||||
<string>ro</string>
|
||||
<string>ru</string>
|
||||
<string>sk</string>
|
||||
<string>sl</string>
|
||||
<string>sr</string>
|
||||
<string>sv</string>
|
||||
<string>th</string>
|
||||
<string>uk</string>
|
||||
<string>vi</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.140.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Share Extension</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Deep Link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>immich</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>219</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>We need local network permission to connect to the local server using IP address and
|
||||
allow the casting feature to work</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true />
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false />
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true />
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true />
|
||||
</dict>
|
||||
</plist>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
@@ -14,6 +15,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
@@ -29,13 +31,9 @@ class BackgroundWorkerFgService {
|
||||
const BackgroundWorkerFgService(this._foregroundHostApi);
|
||||
|
||||
// TODO: Move this call to native side once old timeline is removed
|
||||
Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker();
|
||||
Future<void> enable() => _foregroundHostApi.enable();
|
||||
|
||||
Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker(
|
||||
PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(),
|
||||
);
|
||||
|
||||
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
|
||||
Future<void> disable() => _foregroundHostApi.disable();
|
||||
}
|
||||
|
||||
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
@@ -45,6 +43,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
final DriftLogger _driftLogger;
|
||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||
final Logger _logger = Logger('BackgroundUploadBgService');
|
||||
late final IsolateLockManager _lockManager;
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
@@ -60,98 +59,106 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
driftProvider.overrideWith(driftOverride(drift)),
|
||||
],
|
||||
);
|
||||
_lockManager = IsolateLockManager(onCloseRequest: _cleanup);
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
|
||||
Future<void> init() async {
|
||||
await loadTranslations();
|
||||
HttpSSLOptions.apply(applyNative: false);
|
||||
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
||||
try {
|
||||
await loadTranslations();
|
||||
HttpSSLOptions.apply(applyNative: false);
|
||||
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
||||
|
||||
// Initialize the file downloader
|
||||
await FileDownloader().configure(
|
||||
globalConfig: [
|
||||
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
||||
(Config.holdingQueue, (6, 6, 3)),
|
||||
// On Android, if files are larger than 256MB, run in foreground service
|
||||
(Config.runInForegroundIfFileLargerThan, 256),
|
||||
],
|
||||
);
|
||||
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
|
||||
await FileDownloader().trackTasks();
|
||||
configureFileDownloaderNotifications();
|
||||
// Initialize the file downloader
|
||||
await FileDownloader().configure(
|
||||
globalConfig: [
|
||||
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
||||
(Config.holdingQueue, (6, 6, 3)),
|
||||
// On Android, if files are larger than 256MB, run in foreground service
|
||||
(Config.runInForegroundIfFileLargerThan, 256),
|
||||
],
|
||||
);
|
||||
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
|
||||
await FileDownloader().trackTasks();
|
||||
configureFileDownloaderNotifications();
|
||||
await _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();
|
||||
|
||||
// Notify the host that the background upload service has been initialized and is ready to use
|
||||
await _backgroundHostApi.onInitialized();
|
||||
// Notify the host that the background upload service has been initialized and is ready to use
|
||||
debugPrint("Acquiring background worker lock");
|
||||
if (await _lockManager.acquireLock().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
_lockManager.cancel();
|
||||
return false;
|
||||
},
|
||||
)) {
|
||||
_logger.info("Acquired background worker lock");
|
||||
await _backgroundHostApi.onInitialized();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.warning("Failed to acquire background worker lock");
|
||||
await _cleanup();
|
||||
await _backgroundHostApi.close();
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to initialize background worker", error, stack);
|
||||
_backgroundHostApi.close();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onLocalSync(int? maxSeconds) async {
|
||||
_logger.info('Local background syncing started');
|
||||
final sw = Stopwatch()..start();
|
||||
|
||||
final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null;
|
||||
await _syncAssets(hashTimeout: timeout, syncRemote: false);
|
||||
|
||||
sw.stop();
|
||||
_logger.info("Local sync completed in ${sw.elapsed.inSeconds}s");
|
||||
}
|
||||
|
||||
/* We do the following on Android upload
|
||||
* - Sync local assets
|
||||
* - Hash local assets 3 / 6 minutes
|
||||
* - Sync remote assets
|
||||
* - Check and requeue upload tasks
|
||||
*/
|
||||
@override
|
||||
Future<void> onAndroidUpload() async {
|
||||
_logger.info('Android background processing started');
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
_logger.info('Android background processing started');
|
||||
final sw = Stopwatch()..start();
|
||||
|
||||
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
|
||||
await _handleBackup(processBulk: false);
|
||||
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
|
||||
await _handleBackup(processBulk: false);
|
||||
|
||||
await _cleanup();
|
||||
|
||||
sw.stop();
|
||||
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
|
||||
sw.stop();
|
||||
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to complete Android background processing", error, stack);
|
||||
} finally {
|
||||
await _cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/* We do the following on background upload
|
||||
* - Sync local assets
|
||||
* - Hash local assets
|
||||
* - Sync remote assets
|
||||
* - Check and requeue upload tasks
|
||||
*
|
||||
* The native side will not send the maxSeconds value for processing tasks
|
||||
*/
|
||||
@override
|
||||
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
|
||||
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
|
||||
final sw = Stopwatch()..start();
|
||||
|
||||
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
|
||||
await _syncAssets(hashTimeout: timeout);
|
||||
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
|
||||
await _syncAssets(hashTimeout: timeout);
|
||||
|
||||
final backupFuture = _handleBackup();
|
||||
if (maxSeconds != null) {
|
||||
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
|
||||
} else {
|
||||
await backupFuture;
|
||||
final backupFuture = _handleBackup();
|
||||
if (maxSeconds != null) {
|
||||
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
|
||||
} else {
|
||||
await backupFuture;
|
||||
}
|
||||
|
||||
sw.stop();
|
||||
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to complete iOS background upload", error, stack);
|
||||
} finally {
|
||||
await _cleanup();
|
||||
}
|
||||
|
||||
await _cleanup();
|
||||
|
||||
sw.stop();
|
||||
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel() async {
|
||||
_logger.warning("Background upload cancelled");
|
||||
await _cleanup();
|
||||
_logger.warning("Background worker cancelled");
|
||||
try {
|
||||
await _cleanup();
|
||||
} catch (error, stack) {
|
||||
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
@@ -159,13 +166,22 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
return;
|
||||
}
|
||||
|
||||
_isCleanedUp = true;
|
||||
await _ref.read(backgroundSyncProvider).cancel();
|
||||
await _ref.read(backgroundSyncProvider).cancelLocal();
|
||||
await _isar.close();
|
||||
await _drift.close();
|
||||
await _driftLogger.close();
|
||||
_ref.dispose();
|
||||
try {
|
||||
_isCleanedUp = true;
|
||||
_logger.info("Cleaning up background worker");
|
||||
await _ref.read(backgroundSyncProvider).cancel();
|
||||
await _ref.read(backgroundSyncProvider).cancelLocal();
|
||||
if (_isar.isOpen) {
|
||||
await _isar.close();
|
||||
}
|
||||
await _drift.close();
|
||||
await _driftLogger.close();
|
||||
_ref.dispose();
|
||||
_lockManager.releaseLock();
|
||||
_logger.info("Background worker resources cleaned up");
|
||||
} catch (error, stack) {
|
||||
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleBackup({bool processBulk = true}) async {
|
||||
@@ -190,7 +206,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async {
|
||||
Future<void> _syncAssets({Duration? hashTimeout}) async {
|
||||
final futures = <Future<void>>[];
|
||||
|
||||
final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async {
|
||||
@@ -212,17 +228,16 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
});
|
||||
|
||||
futures.add(localSyncFuture);
|
||||
if (syncRemote) {
|
||||
final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote();
|
||||
futures.add(remoteSyncFuture);
|
||||
}
|
||||
futures.add(_ref.read(backgroundSyncProvider).syncRemote());
|
||||
|
||||
await Future.wait(futures);
|
||||
}
|
||||
}
|
||||
|
||||
/// Native entry invoked from the background worker. If renaming or moving this to a different
|
||||
/// library, make sure to update the entry points and URI in native workers as well
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _backgroundSyncNativeEntrypoint() async {
|
||||
Future<void> backgroundSyncNativeEntrypoint() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
@@ -35,6 +36,7 @@ class HashService {
|
||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||
|
||||
Future<void> hashAssets() async {
|
||||
_log.info("Starting hashing of assets");
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
// Sorted by backupSelection followed by isCloud
|
||||
final localAlbums = await _localAlbumRepository.getAll(
|
||||
@@ -49,7 +51,7 @@ class HashService {
|
||||
|
||||
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
||||
if (assetsToHash.isNotEmpty) {
|
||||
await _hashAssets(assetsToHash);
|
||||
await _hashAssets(album, assetsToHash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +62,7 @@ class HashService {
|
||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||
Future<void> _hashAssets(List<LocalAsset> assetsToHash) async {
|
||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
||||
int bytesProcessed = 0;
|
||||
final toHash = <_AssetToPath>[];
|
||||
|
||||
@@ -72,6 +74,9 @@ class HashService {
|
||||
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
_log.warning(
|
||||
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -79,17 +84,17 @@ class HashService {
|
||||
toHash.add(_AssetToPath(asset: asset, path: file.path));
|
||||
|
||||
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
|
||||
await _processBatch(toHash);
|
||||
await _processBatch(album, toHash);
|
||||
toHash.clear();
|
||||
bytesProcessed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await _processBatch(toHash);
|
||||
await _processBatch(album, toHash);
|
||||
}
|
||||
|
||||
/// Processes a batch of assets.
|
||||
Future<void> _processBatch(List<_AssetToPath> toHash) async {
|
||||
Future<void> _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async {
|
||||
if (toHash.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +119,9 @@ class HashService {
|
||||
if (hash?.length == 20) {
|
||||
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
|
||||
} else {
|
||||
_log.warning("Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt}");
|
||||
_log.warning(
|
||||
"Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:platform/platform.dart';
|
||||
@@ -285,7 +286,7 @@ extension on Iterable<PlatformAlbum> {
|
||||
(e) => LocalAlbum(
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
|
||||
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(),
|
||||
assetCount: e.assetCount,
|
||||
),
|
||||
).toList();
|
||||
@@ -300,8 +301,8 @@ extension on Iterable<PlatformAsset> {
|
||||
name: e.name,
|
||||
checksum: null,
|
||||
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
|
||||
createdAt: e.createdAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000),
|
||||
updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
|
||||
createdAt: tryFromSecondsSinceEpoch(e.createdAt) ?? DateTime.now(),
|
||||
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(),
|
||||
width: e.width,
|
||||
height: e.height,
|
||||
durationInSeconds: e.durationInSeconds,
|
||||
|
||||
235
mobile/lib/domain/utils/isolate_lock_manager.dart
Normal file
235
mobile/lib/domain/utils/isolate_lock_manager.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
const String kIsolateLockManagerPort = "immich://isolate_mutex";
|
||||
|
||||
enum _LockStatus { active, released }
|
||||
|
||||
class _IsolateRequest {
|
||||
const _IsolateRequest();
|
||||
}
|
||||
|
||||
class _HeartbeatRequest extends _IsolateRequest {
|
||||
// Port for the receiver to send replies back
|
||||
final SendPort sendPort;
|
||||
|
||||
const _HeartbeatRequest(this.sendPort);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'type': 'heartbeat', 'sendPort': sendPort};
|
||||
}
|
||||
}
|
||||
|
||||
class _CloseRequest extends _IsolateRequest {
|
||||
const _CloseRequest();
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'type': 'close'};
|
||||
}
|
||||
}
|
||||
|
||||
class _IsolateResponse {
|
||||
const _IsolateResponse();
|
||||
}
|
||||
|
||||
class _HeartbeatResponse extends _IsolateResponse {
|
||||
final _LockStatus status;
|
||||
|
||||
const _HeartbeatResponse(this.status);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'type': 'heartbeat', 'status': status.index};
|
||||
}
|
||||
}
|
||||
|
||||
typedef OnCloseLockHolderRequest = void Function();
|
||||
|
||||
class IsolateLockManager {
|
||||
final String _portName;
|
||||
bool _hasLock = false;
|
||||
ReceivePort? _receivePort;
|
||||
final OnCloseLockHolderRequest? _onCloseRequest;
|
||||
final Set<SendPort> _waitingIsolates = {};
|
||||
// Token object - a new one is created for each acquisition attempt
|
||||
Object? _currentAcquisitionToken;
|
||||
|
||||
IsolateLockManager({String? portName, OnCloseLockHolderRequest? onCloseRequest})
|
||||
: _portName = portName ?? kIsolateLockManagerPort,
|
||||
_onCloseRequest = onCloseRequest;
|
||||
|
||||
Future<bool> acquireLock() async {
|
||||
if (_hasLock) {
|
||||
Logger('BackgroundWorkerLockManager').warning("WARNING: [acquireLock] called more than once");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create a new token - this invalidates any previous attempt
|
||||
final token = _currentAcquisitionToken = Object();
|
||||
|
||||
final ReceivePort rp = _receivePort = ReceivePort(_portName);
|
||||
final SendPort sp = rp.sendPort;
|
||||
|
||||
while (!IsolateNameServer.registerPortWithName(sp, _portName)) {
|
||||
// This attempt was superseded by a newer one in the same isolate
|
||||
if (_currentAcquisitionToken != token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _lockReleasedByHolder(token);
|
||||
}
|
||||
|
||||
_hasLock = true;
|
||||
rp.listen(_onRequest);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _lockReleasedByHolder(Object token) async {
|
||||
SendPort? holder = IsolateNameServer.lookupPortByName(_portName);
|
||||
debugPrint("Found lock holder: $holder");
|
||||
if (holder == null) {
|
||||
// No holder, try and acquire lock
|
||||
return;
|
||||
}
|
||||
|
||||
final ReceivePort tempRp = ReceivePort();
|
||||
final SendPort tempSp = tempRp.sendPort;
|
||||
final bs = tempRp.asBroadcastStream();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// Send a heartbeat request with the send port to receive reply from the holder
|
||||
|
||||
debugPrint("Sending heartbeat request to lock holder");
|
||||
holder.send(_HeartbeatRequest(tempSp).toJson());
|
||||
dynamic answer = await bs.first.timeout(const Duration(seconds: 3), onTimeout: () => null);
|
||||
|
||||
debugPrint("Received heartbeat response from lock holder: $answer");
|
||||
// This attempt was superseded by a newer one in the same isolate
|
||||
if (_currentAcquisitionToken != token) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (answer == null) {
|
||||
// Holder failed, most likely killed without calling releaseLock
|
||||
// Check if a different waiting isolate took the lock
|
||||
if (holder == IsolateNameServer.lookupPortByName(_portName)) {
|
||||
// No, remove the stale lock
|
||||
IsolateNameServer.removePortNameMapping(_portName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Unknown message type received for heartbeat request. Try again
|
||||
_IsolateResponse? response = _parseResponse(answer);
|
||||
if (response == null || response is! _HeartbeatResponse) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.status == _LockStatus.released) {
|
||||
// Holder has released the lock
|
||||
break;
|
||||
}
|
||||
|
||||
// If the _LockStatus is active, we check again if the task completed
|
||||
// by sending a released messaged again, if not, send a new heartbeat again
|
||||
|
||||
// Check if the holder completed its task after the heartbeat
|
||||
answer = await bs.first.timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () => const _HeartbeatResponse(_LockStatus.active).toJson(),
|
||||
);
|
||||
|
||||
response = _parseResponse(answer);
|
||||
if (response is _HeartbeatResponse && response.status == _LockStatus.released) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Timeout or error
|
||||
} finally {
|
||||
tempRp.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_IsolateRequest? _parseRequest(dynamic msg) {
|
||||
if (msg is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (msg['type']) {
|
||||
'heartbeat' => _HeartbeatRequest(msg['sendPort']),
|
||||
'close' => const _CloseRequest(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
_IsolateResponse? _parseResponse(dynamic msg) {
|
||||
if (msg is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (msg['type']) {
|
||||
'heartbeat' => _HeartbeatResponse(_LockStatus.values[msg['status']]),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Executed in the isolate with the lock
|
||||
void _onRequest(dynamic msg) {
|
||||
final request = _parseRequest(msg);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request is _HeartbeatRequest) {
|
||||
// Add the send port to the list of waiting isolates
|
||||
_waitingIsolates.add(request.sendPort);
|
||||
request.sendPort.send(const _HeartbeatResponse(_LockStatus.active).toJson());
|
||||
return;
|
||||
}
|
||||
|
||||
if (request is _CloseRequest) {
|
||||
_onCloseRequest?.call();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void releaseLock() {
|
||||
if (_hasLock) {
|
||||
IsolateNameServer.removePortNameMapping(_portName);
|
||||
|
||||
// Notify waiting isolates
|
||||
for (final port in _waitingIsolates) {
|
||||
port.send(const _HeartbeatResponse(_LockStatus.released).toJson());
|
||||
}
|
||||
_waitingIsolates.clear();
|
||||
|
||||
_hasLock = false;
|
||||
}
|
||||
|
||||
_receivePort?.close();
|
||||
_receivePort = null;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
if (_hasLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint("Cancelling ongoing acquire lock attempts");
|
||||
// Create a new token to invalidate ongoing acquire lock attempts
|
||||
_currentAcquisitionToken = Object();
|
||||
}
|
||||
|
||||
void requestHolderToClose() {
|
||||
if (_hasLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
IsolateNameServer.lookupPortByName(_portName)?.send(const _CloseRequest().toJson());
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cronet_http/cronet_http.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
part 'local_image_request.dart';
|
||||
part 'thumbhash_image_request.dart';
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
part of 'image_request.dart';
|
||||
|
||||
class RemoteImageRequest extends ImageRequest {
|
||||
static final _client = const NetworkRepository().getHttpClient(
|
||||
'thumbnails',
|
||||
diskCapacity: kThumbnailDiskCacheSize,
|
||||
memoryCapacity: 0,
|
||||
maxConnections: 16,
|
||||
cacheMode: CacheMode.disk,
|
||||
);
|
||||
static final log = Logger('RemoteImageRequest');
|
||||
static final client = HttpClient()..maxConnectionsPerHost = 16;
|
||||
final RemoteCacheManager? cacheManager;
|
||||
final String uri;
|
||||
final Map<String, String> headers;
|
||||
final abortTrigger = Completer<void>();
|
||||
HttpClientRequest? _request;
|
||||
|
||||
RemoteImageRequest({required this.uri, required this.headers});
|
||||
RemoteImageRequest({required this.uri, required this.headers, this.cacheManager});
|
||||
|
||||
@override
|
||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||
@@ -20,8 +16,15 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
|
||||
// so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call.
|
||||
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
|
||||
if (cachedFileImage != null) {
|
||||
return cachedFileImage;
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await _downloadImage();
|
||||
final buffer = await _downloadImage(uri);
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -32,41 +35,57 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
|
||||
if (cachedFileImage != null) {
|
||||
return cachedFileImage;
|
||||
}
|
||||
|
||||
rethrow;
|
||||
} finally {
|
||||
_request = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImmutableBuffer?> _downloadImage() async {
|
||||
Future<ImmutableBuffer?> _downloadImage(String url) async {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final req = http.AbortableRequest('GET', Uri.parse(uri), abortTrigger: abortTrigger.future);
|
||||
req.headers.addAll(headers);
|
||||
final res = await _client.send(req);
|
||||
final request = _request = await client.getUrl(Uri.parse(url));
|
||||
if (_isCancelled) {
|
||||
request.abort();
|
||||
return _request = null;
|
||||
}
|
||||
|
||||
for (final entry in headers.entries) {
|
||||
request.headers.set(entry.key, entry.value);
|
||||
}
|
||||
final response = await request.close();
|
||||
if (_isCancelled) {
|
||||
_onCancelled();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('Failed to download $uri: ${res.statusCode}');
|
||||
}
|
||||
|
||||
final stream = res.stream.map((chunk) {
|
||||
final cacheManager = this.cacheManager;
|
||||
final streamController = StreamController<List<int>>(sync: true);
|
||||
final Stream<List<int>> stream;
|
||||
cacheManager?.putStreamedFile(url, streamController.stream);
|
||||
stream = response.map((chunk) {
|
||||
if (_isCancelled) {
|
||||
throw StateError('Cancelled request');
|
||||
}
|
||||
if (cacheManager != null) {
|
||||
streamController.add(chunk);
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
|
||||
try {
|
||||
final Uint8List bytes = await _downloadBytes(stream, res.contentLength ?? -1);
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
|
||||
streamController.close();
|
||||
return await ImmutableBuffer.fromUint8List(bytes);
|
||||
} catch (e) {
|
||||
streamController.addError(e);
|
||||
streamController.close();
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
@@ -103,6 +122,40 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
Future<ImageInfo?> _loadCachedFile(
|
||||
String url,
|
||||
ImageDecoderCallback decode,
|
||||
double scale, {
|
||||
required bool inMemoryOnly,
|
||||
}) async {
|
||||
final cacheManager = this.cacheManager;
|
||||
if (_isCancelled || cacheManager == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
|
||||
if (_isCancelled || file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
|
||||
return await _decodeBuffer(buffer, decode, scale);
|
||||
} catch (e) {
|
||||
log.severe('Failed to decode cached image', e);
|
||||
_evictFile(url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _evictFile(String url) async {
|
||||
try {
|
||||
await cacheManager?.removeFile(url);
|
||||
} catch (e) {
|
||||
log.severe('Failed to remove cached image', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
@@ -120,6 +173,7 @@ class RemoteImageRequest extends ImageRequest {
|
||||
|
||||
@override
|
||||
void _onCancelled() {
|
||||
abortTrigger.complete();
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cronet_http/cronet_http.dart';
|
||||
import 'package:cupertino_http/cupertino_http.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:immich_mobile/utils/user_agent.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class NetworkRepository {
|
||||
static late Directory _cachePath;
|
||||
static late String _userAgent;
|
||||
static final _clients = <String, http.Client>{};
|
||||
|
||||
static Future<void> init() {
|
||||
return (
|
||||
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
|
||||
getUserAgentString().then((userAgent) => _userAgent = userAgent),
|
||||
).wait;
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
Future.microtask(init);
|
||||
for (final client in _clients.values) {
|
||||
client.close();
|
||||
}
|
||||
_clients.clear();
|
||||
}
|
||||
|
||||
const NetworkRepository();
|
||||
|
||||
http.Client getHttpClient(
|
||||
String directoryName, {
|
||||
int diskCapacity = 100 << 20,
|
||||
int memoryCapacity = 10 << 20,
|
||||
int maxConnections = 6,
|
||||
CacheMode cacheMode = CacheMode.disk,
|
||||
}) {
|
||||
final cachedClient = _clients[directoryName];
|
||||
if (cachedClient != null) {
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
final directory = Directory('${_cachePath.path}/$directoryName');
|
||||
directory.createSync(recursive: true);
|
||||
if (Platform.isAndroid) {
|
||||
final engine = CronetEngine.build(
|
||||
cacheMode: cacheMode,
|
||||
cacheMaxSize: diskCapacity,
|
||||
storagePath: directory.path,
|
||||
userAgent: _userAgent,
|
||||
);
|
||||
return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true);
|
||||
}
|
||||
|
||||
final config = URLSessionConfiguration.defaultSessionConfiguration()
|
||||
..httpMaximumConnectionsPerHost = maxConnections
|
||||
..cache = URLCache.withCapacity(
|
||||
diskCapacity: diskCapacity,
|
||||
memoryCapacity: memoryCapacity,
|
||||
directory: directory.uri,
|
||||
)
|
||||
..httpAdditionalHeaders = {'User-Agent': _userAgent};
|
||||
return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ class StorageRepository {
|
||||
file = await entity?.originFile;
|
||||
if (file == null) {
|
||||
log.warning("Cannot get file for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
final exists = await file.exists();
|
||||
if (!exists) {
|
||||
log.warning("File for asset $assetId does not exist");
|
||||
return null;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error getting file for asset $assetId", error, stackTrace);
|
||||
@@ -34,6 +41,13 @@ class StorageRepository {
|
||||
log.warning(
|
||||
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final exists = await file.exists();
|
||||
if (!exists) {
|
||||
log.warning("Motion file for asset ${asset.id} does not exist");
|
||||
return null;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning(
|
||||
|
||||
@@ -4,13 +4,11 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SyncApiRepository {
|
||||
static final _client = const NetworkRepository().getHttpClient('api');
|
||||
final Logger _logger = Logger('SyncApiRepository');
|
||||
final ApiService _api;
|
||||
SyncApiRepository(this._api);
|
||||
@@ -22,8 +20,10 @@ class SyncApiRepository {
|
||||
Future<void> streamChanges(
|
||||
Function(List<SyncEvent>, Function() abort) onData, {
|
||||
int batchSize = kSyncEventBatchSize,
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final client = httpClient ?? http.Client();
|
||||
final endpoint = "${_api.apiClient.basePath}/sync/stream";
|
||||
|
||||
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
|
||||
@@ -70,7 +70,7 @@ class SyncApiRepository {
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _client.send(request);
|
||||
final response = await client.send(request);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final errorBody = await response.stream.bytesToString();
|
||||
@@ -101,6 +101,8 @@ class SyncApiRepository {
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Error processing stream", error, stack);
|
||||
return Future.error(error, stack);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
stopwatch.stop();
|
||||
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
|
||||
|
||||
@@ -15,9 +15,7 @@ import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
@@ -27,7 +25,6 @@ import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:immich_mobile/services/deep_link.service.dart';
|
||||
import 'package:immich_mobile/services/local_notification.service.dart';
|
||||
@@ -115,8 +112,6 @@ Future<void> initApp() async {
|
||||
yield LicenseEntryWithLineBreaks([license.key], license.value);
|
||||
}
|
||||
});
|
||||
|
||||
await NetworkRepository.init();
|
||||
}
|
||||
|
||||
class ImmichApp extends ConsumerStatefulWidget {
|
||||
@@ -209,14 +204,11 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// needs to be delayed so that EasyLocalization is working
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
ref.read(driftBackgroundUploadFgService).enableSyncService();
|
||||
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
|
||||
ref.read(backgroundServiceProvider).disableService();
|
||||
ref.read(driftBackgroundUploadFgService).enableUploadService();
|
||||
}
|
||||
ref.read(backgroundServiceProvider).disableService();
|
||||
ref.read(driftBackgroundUploadFgService).enable();
|
||||
} else {
|
||||
ref.read(driftBackgroundUploadFgService).disable();
|
||||
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||
ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -229,14 +221,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
if (kDebugMode) {
|
||||
NetworkRepository.reset();
|
||||
}
|
||||
super.reassemble();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -43,12 +42,10 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
|
||||
await ref.read(backgroundSyncProvider).syncRemote();
|
||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||
await ref.read(driftBackgroundUploadFgService).enableUploadService();
|
||||
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
|
||||
}
|
||||
|
||||
Future<void> stopBackup() async {
|
||||
await ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||
await ref.read(driftBackupProvider.notifier).cancel();
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
|
||||
ref.read(readonlyModeProvider.notifier).setReadonlyMode(false);
|
||||
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
|
||||
await ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||
await ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||
await ref.read(driftBackgroundUploadFgService).disable();
|
||||
}
|
||||
|
||||
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
|
||||
|
||||
@@ -2,8 +2,10 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
@@ -21,14 +23,23 @@ class SplashScreenPage extends StatefulHookConsumerWidget {
|
||||
|
||||
class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
final log = Logger("SplashScreenPage");
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref
|
||||
.read(authProvider.notifier)
|
||||
.setOpenApiServiceEndpoint()
|
||||
.then(logConnectionInfo)
|
||||
.whenComplete(() => resumeSession());
|
||||
final lockManager = ref.read(isolateLockManagerProvider(kIsolateLockManagerPort));
|
||||
|
||||
lockManager.requestHolderToClose();
|
||||
lockManager
|
||||
.acquireLock()
|
||||
.timeout(const Duration(seconds: 5))
|
||||
.whenComplete(
|
||||
() => ref
|
||||
.read(authProvider.notifier)
|
||||
.setOpenApiServiceEndpoint()
|
||||
.then(logConnectionInfo)
|
||||
.whenComplete(() => resumeSession()),
|
||||
);
|
||||
}
|
||||
|
||||
void logConnectionInfo(String? endpoint) {
|
||||
|
||||
85
mobile/lib/platform/background_worker_api.g.dart
generated
85
mobile/lib/platform/background_worker_api.g.dart
generated
@@ -59,9 +59,9 @@ class BackgroundWorkerFgHostApi {
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<void> enableSyncWorker() async {
|
||||
Future<void> enable() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$pigeonVar_messageChannelSuffix';
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -82,32 +82,9 @@ class BackgroundWorkerFgHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enableUploadWorker(int callbackHandle) async {
|
||||
Future<void> disable() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[callbackHandle]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disableUploadWorker() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$pigeonVar_messageChannelSuffix';
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -164,13 +141,34 @@ class BackgroundWorkerBgHostApi {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BackgroundWorkerFlutterApi {
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
Future<void> onLocalSync(int? maxSeconds);
|
||||
|
||||
Future<void> onIosUpload(bool isRefresh, int? maxSeconds);
|
||||
|
||||
Future<void> onAndroidUpload();
|
||||
@@ -183,35 +181,6 @@ abstract class BackgroundWorkerFlutterApi {
|
||||
String messageChannelSuffix = '',
|
||||
}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
{
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$messageChannelSuffix',
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger,
|
||||
);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
assert(
|
||||
message != null,
|
||||
'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync was null.',
|
||||
);
|
||||
final List<Object?> args = (message as List<Object?>?)!;
|
||||
final int? arg_maxSeconds = (args[0] as int?);
|
||||
try {
|
||||
await api.onLocalSync(arg_maxSeconds);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(
|
||||
error: PlatformException(code: 'error', message: e.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
{
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
@@ -38,14 +39,14 @@ class DriftPlacePage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceSliverAppBar extends StatelessWidget {
|
||||
class _PlaceSliverAppBar extends HookWidget {
|
||||
const _PlaceSliverAppBar({required this.search});
|
||||
|
||||
final ValueNotifier<String?> search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final searchFocusNode = FocusNode();
|
||||
final searchFocusNode = useFocusNode();
|
||||
|
||||
return SliverAppBar(
|
||||
floating: true,
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/album_filter.utils.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
@@ -39,8 +40,12 @@ class AlbumSelector extends ConsumerStatefulWidget {
|
||||
class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
bool isGrid = false;
|
||||
final searchController = TextEditingController();
|
||||
QuickFilterMode filterMode = QuickFilterMode.all;
|
||||
final searchFocusNode = FocusNode();
|
||||
List<RemoteAlbum> sortedAlbums = [];
|
||||
List<RemoteAlbum> shownAlbums = [];
|
||||
|
||||
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
|
||||
AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -52,7 +57,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
});
|
||||
|
||||
searchController.addListener(() {
|
||||
onSearch(searchController.text, filterMode);
|
||||
onSearch(searchController.text, filter.mode);
|
||||
});
|
||||
|
||||
searchFocusNode.addListener(() {
|
||||
@@ -62,9 +67,11 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
});
|
||||
}
|
||||
|
||||
void onSearch(String searchTerm, QuickFilterMode sortMode) {
|
||||
void onSearch(String searchTerm, QuickFilterMode filterMode) {
|
||||
final userId = ref.watch(currentUserProvider)?.id;
|
||||
ref.read(remoteAlbumProvider.notifier).searchAlbums(searchTerm, userId, sortMode);
|
||||
filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode);
|
||||
|
||||
filterAlbums();
|
||||
}
|
||||
|
||||
Future<void> onRefresh() async {
|
||||
@@ -77,17 +84,60 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
});
|
||||
}
|
||||
|
||||
void changeFilter(QuickFilterMode sortMode) {
|
||||
void changeFilter(QuickFilterMode mode) {
|
||||
setState(() {
|
||||
filterMode = sortMode;
|
||||
filter = filter.copyWith(mode: mode);
|
||||
});
|
||||
|
||||
filterAlbums();
|
||||
}
|
||||
|
||||
Future<void> changeSort(AlbumSort sort) async {
|
||||
setState(() {
|
||||
this.sort = sort;
|
||||
});
|
||||
|
||||
await sortAlbums();
|
||||
}
|
||||
|
||||
void clearSearch() {
|
||||
setState(() {
|
||||
filterMode = QuickFilterMode.all;
|
||||
filter = filter.copyWith(mode: QuickFilterMode.all, query: null);
|
||||
searchController.clear();
|
||||
ref.read(remoteAlbumProvider.notifier).clearSearch();
|
||||
});
|
||||
|
||||
filterAlbums();
|
||||
}
|
||||
|
||||
Future<void> sortAlbums() async {
|
||||
final sorted = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.sortAlbums(ref.read(remoteAlbumProvider).albums, sort.mode, isReverse: sort.isReverse);
|
||||
|
||||
setState(() {
|
||||
sortedAlbums = sorted;
|
||||
});
|
||||
|
||||
// we need to re-filter the albums after sorting
|
||||
// so shownAlbums gets updated
|
||||
filterAlbums();
|
||||
}
|
||||
|
||||
Future<void> filterAlbums() async {
|
||||
if (filter.query == null) {
|
||||
setState(() {
|
||||
shownAlbums = sortedAlbums;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
final filteredAlbums = ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.searchAlbums(sortedAlbums, filter.query!, filter.userId, filter.mode);
|
||||
|
||||
setState(() {
|
||||
shownAlbums = filteredAlbums;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,36 +150,41 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final albums = ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums));
|
||||
|
||||
final userId = ref.watch(currentUserProvider)?.id;
|
||||
|
||||
// refilter and sort when albums change
|
||||
ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async {
|
||||
await sortAlbums();
|
||||
});
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
_SearchBar(
|
||||
searchController: searchController,
|
||||
searchFocusNode: searchFocusNode,
|
||||
onSearch: onSearch,
|
||||
filterMode: filterMode,
|
||||
filterMode: filter.mode,
|
||||
onClearSearch: clearSearch,
|
||||
),
|
||||
_QuickFilterButtonRow(
|
||||
filterMode: filterMode,
|
||||
filterMode: filter.mode,
|
||||
onChangeFilter: changeFilter,
|
||||
onSearch: onSearch,
|
||||
searchController: searchController,
|
||||
),
|
||||
_QuickSortAndViewMode(isGrid: isGrid, onToggleViewMode: toggleViewMode),
|
||||
_QuickSortAndViewMode(isGrid: isGrid, onToggleViewMode: toggleViewMode, onSortChanged: changeSort),
|
||||
isGrid
|
||||
? _AlbumGrid(albums: albums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
|
||||
: _AlbumList(albums: albums, userId: userId, onAlbumSelected: widget.onAlbumSelected),
|
||||
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
|
||||
: _AlbumList(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SortButton extends ConsumerStatefulWidget {
|
||||
const _SortButton();
|
||||
const _SortButton(this.onSortChanged);
|
||||
|
||||
final Future<void> Function(AlbumSort) onSortChanged;
|
||||
|
||||
@override
|
||||
ConsumerState<_SortButton> createState() => _SortButtonState();
|
||||
@@ -148,15 +203,15 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
albumSortIsReverse = !albumSortIsReverse;
|
||||
isSorting = true;
|
||||
});
|
||||
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
||||
} else {
|
||||
setState(() {
|
||||
albumSortOption = sortMode;
|
||||
isSorting = true;
|
||||
});
|
||||
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
||||
}
|
||||
|
||||
await widget.onSortChanged.call(AlbumSort(mode: albumSortOption, isReverse: albumSortIsReverse));
|
||||
|
||||
setState(() {
|
||||
isSorting = false;
|
||||
});
|
||||
@@ -394,10 +449,11 @@ class _QuickFilterButton extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _QuickSortAndViewMode extends StatelessWidget {
|
||||
const _QuickSortAndViewMode({required this.isGrid, required this.onToggleViewMode});
|
||||
const _QuickSortAndViewMode({required this.isGrid, required this.onToggleViewMode, required this.onSortChanged});
|
||||
|
||||
final bool isGrid;
|
||||
final VoidCallback onToggleViewMode;
|
||||
final Future<void> Function(AlbumSort) onSortChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -407,7 +463,7 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const _SortButton(),
|
||||
_SortButton(onSortChanged),
|
||||
IconButton(
|
||||
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
|
||||
onPressed: onToggleViewMode,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
@@ -129,6 +130,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
reloadSubscription?.cancel();
|
||||
_prevPreCacheStream?.removeListener(_dummyListener);
|
||||
_nextPreCacheStream?.removeListener(_dummyListener);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -596,6 +598,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
// Rebuild the widget when the asset viewer state changes
|
||||
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
|
||||
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
ref.watch(isPlayingMotionVideoProvider);
|
||||
@@ -612,6 +615,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for control visibility changes and change system UI mode accordingly
|
||||
ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async {
|
||||
if (showingControls) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
}
|
||||
});
|
||||
|
||||
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
|
||||
// Issue: https://github.com/flutter/flutter/issues/109037
|
||||
// TODO: Add a custom scrum builder once the fix lands on stable
|
||||
|
||||
@@ -62,7 +62,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
duration: Durations.short2,
|
||||
child: AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: isSheetOpen || isReadonlyModeEnabled
|
||||
child: isSheetOpen
|
||||
? const SizedBox.shrink()
|
||||
: Theme(
|
||||
data: context.themeData.copyWith(
|
||||
@@ -72,14 +72,14 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
height: context.padding.bottom + (asset.isVideo ? 160 : 90),
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (!isInLockedView) Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
if (!isInLockedView && !isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
@@ -16,22 +18,74 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RemoteAlbumBottomSheet extends ConsumerWidget {
|
||||
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
|
||||
final RemoteAlbum album;
|
||||
const RemoteAlbumBottomSheet({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<RemoteAlbumBottomSheet> createState() => _RemoteAlbumBottomSheetState();
|
||||
}
|
||||
|
||||
class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet> {
|
||||
late DraggableScrollableController sheetController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
sheetController = DraggableScrollableController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
sheetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
|
||||
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||
final selectedAssets = multiselect.selectedAssets;
|
||||
if (selectedAssets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final addedCount = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
|
||||
|
||||
if (addedCount != selectedAssets.length) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name}),
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
|
||||
Future<void> onKeyboardExpand() {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.4,
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.45,
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
@@ -52,7 +106,11 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
|
||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
const UploadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: album.id),
|
||||
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,11 +62,6 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
return;
|
||||
}
|
||||
yield image;
|
||||
} catch (e) {
|
||||
evict();
|
||||
if (!isCancelled) {
|
||||
_log.severe('Error loading image', e);
|
||||
}
|
||||
} finally {
|
||||
this.request = null;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
with CancellableImageProviderMixin<RemoteThumbProvider> {
|
||||
static final cacheManager = RemoteThumbnailCacheManager();
|
||||
final String assetId;
|
||||
|
||||
RemoteThumbProvider({required this.assetId});
|
||||
@@ -37,6 +39,7 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
return loadRequest(request, decode);
|
||||
}
|
||||
@@ -57,6 +60,7 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
|
||||
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
||||
with CancellableImageProviderMixin<RemoteFullImageProvider> {
|
||||
static final cacheManager = RemoteThumbnailCacheManager();
|
||||
final String assetId;
|
||||
|
||||
RemoteFullImageProvider({required this.assetId});
|
||||
@@ -88,7 +92,11 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
}
|
||||
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final request = this.request = RemoteImageRequest(uri: getPreviewUrlForRemoteId(key.assetId), headers: headers);
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getPreviewUrlForRemoteId(key.assetId),
|
||||
headers: headers,
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
yield* loadRequest(request, decode);
|
||||
|
||||
if (isCancelled) {
|
||||
|
||||
@@ -2,11 +2,9 @@ import 'dart:ui';
|
||||
|
||||
const double kTimelineHeaderExtent = 80.0;
|
||||
const Size kTimelineFixedTileExtent = Size.square(256);
|
||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
||||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
||||
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
||||
|
||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
||||
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:intl/intl.dart' hide TextDirection;
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
@@ -74,6 +75,7 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
|
||||
}
|
||||
|
||||
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
|
||||
String? _lastLabel;
|
||||
double _thumbTopOffset = 0.0;
|
||||
bool _isDragging = false;
|
||||
List<_Segment> _segments = [];
|
||||
@@ -172,6 +174,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
_isDragging = true;
|
||||
_labelAnimationController.forward();
|
||||
_fadeOutTimer?.cancel();
|
||||
_lastLabel = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,6 +192,11 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
|
||||
if (nearestMonthSegment != null) {
|
||||
_snapToSegment(nearestMonthSegment);
|
||||
final label = nearestMonthSegment.scrollLabel;
|
||||
if (_lastLabel != label) {
|
||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||
_lastLabel = label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
@@ -81,6 +82,12 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
}
|
||||
} else {
|
||||
_ref.read(backupProvider.notifier).cancelBackup();
|
||||
final lockManager = _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort));
|
||||
|
||||
lockManager.requestHolderToClose();
|
||||
debugPrint("Requested lock holder to close on resume");
|
||||
await lockManager.acquireLock();
|
||||
debugPrint("Lock acquired for background sync on resume");
|
||||
|
||||
final backgroundManager = _ref.read(backgroundSyncProvider);
|
||||
// Ensure proper cleanup before starting new background tasks
|
||||
@@ -130,7 +137,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
// do not stop/clean up anything on inactivity: issued on every orientation change
|
||||
}
|
||||
|
||||
void handleAppPause() {
|
||||
Future<void> handleAppPause() async {
|
||||
state = AppLifeCycleEnum.paused;
|
||||
_wasPaused = true;
|
||||
|
||||
@@ -140,6 +147,12 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) {
|
||||
_ref.read(backupProvider.notifier).cancelBackup();
|
||||
}
|
||||
} else {
|
||||
final backgroundManager = _ref.read(backgroundSyncProvider);
|
||||
await backgroundManager.cancel();
|
||||
await backgroundManager.cancelLocal();
|
||||
_ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock();
|
||||
debugPrint("Lock released on app pause");
|
||||
}
|
||||
|
||||
_ref.read(websocketProvider.notifier).disconnect();
|
||||
@@ -173,6 +186,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
}
|
||||
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
_ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
|
||||
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
|
||||
@@ -18,3 +19,7 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
|
||||
ref.onDispose(manager.cancel);
|
||||
return manager;
|
||||
});
|
||||
|
||||
final isolateLockManagerProvider = Provider.family<IsolateLockManager, String>((ref, name) {
|
||||
return IsolateLockManager(portName: name);
|
||||
});
|
||||
|
||||
@@ -1,25 +1,148 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:flutter_cache_manager/src/cache_store.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class RemoteImageCacheManager extends CacheManager {
|
||||
abstract class RemoteCacheManager extends CacheManager {
|
||||
static final _log = Logger('RemoteCacheManager');
|
||||
|
||||
RemoteCacheManager.custom(super.config, CacheStore store)
|
||||
// Unfortunately, CacheStore is not a public API
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
: super.custom(cacheStore: store);
|
||||
|
||||
Future<void> putStreamedFile(
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
});
|
||||
|
||||
// Unlike `putFileStream`, this method handles request cancellation,
|
||||
// does not make a (slow) DB call checking if the file is already cached,
|
||||
// does not synchronously check if a file exists,
|
||||
// and deletes the file on cancellation without making these checks again.
|
||||
Future<void> putStreamedFileToStore(
|
||||
CacheStore store,
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
}) async {
|
||||
final path = '${const Uuid().v1()}.$fileExtension';
|
||||
final file = await store.fileSystem.createFile(path);
|
||||
final sink = file.openWrite();
|
||||
try {
|
||||
await source.listen(sink.add, cancelOnError: true).asFuture();
|
||||
} catch (e) {
|
||||
try {
|
||||
await sink.close();
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.severe('Failed to delete incomplete cache file: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
} catch (e) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.severe('Failed to delete incomplete cache file: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final cacheObject = CacheObject(
|
||||
url,
|
||||
key: key,
|
||||
relativePath: path,
|
||||
validTill: DateTime.now().add(maxAge),
|
||||
eTag: eTag,
|
||||
);
|
||||
try {
|
||||
await store.putFile(cacheObject);
|
||||
} catch (e) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.severe('Failed to delete untracked cache file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteImageCacheManager extends RemoteCacheManager {
|
||||
static const key = 'remoteImageCacheKey';
|
||||
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
|
||||
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
|
||||
static final _store = CacheStore(_config);
|
||||
|
||||
factory RemoteImageCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
RemoteImageCacheManager._() : super(_config);
|
||||
RemoteImageCacheManager._() : super.custom(_config, _store);
|
||||
|
||||
@override
|
||||
Future<void> putStreamedFile(
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
}) {
|
||||
return putStreamedFileToStore(
|
||||
_store,
|
||||
url,
|
||||
source,
|
||||
key: key,
|
||||
eTag: eTag,
|
||||
maxAge: maxAge,
|
||||
fileExtension: fileExtension,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteThumbnailCacheManager extends CacheManager {
|
||||
/// The cache manager for full size images [ImmichRemoteImageProvider]
|
||||
class RemoteThumbnailCacheManager extends RemoteCacheManager {
|
||||
static const key = 'remoteThumbnailCacheKey';
|
||||
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
|
||||
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
|
||||
static final _store = CacheStore(_config);
|
||||
|
||||
factory RemoteThumbnailCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
RemoteThumbnailCacheManager._() : super(_config);
|
||||
RemoteThumbnailCacheManager._() : super.custom(_config, _store);
|
||||
|
||||
@override
|
||||
Future<void> putStreamedFile(
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
}) {
|
||||
return putStreamedFileToStore(
|
||||
_store,
|
||||
url,
|
||||
source,
|
||||
key: key,
|
||||
eTag: eTag,
|
||||
maxAge: maxAge,
|
||||
fileExtension: fileExtension,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,43 +12,42 @@ import 'album.provider.dart';
|
||||
|
||||
class RemoteAlbumState {
|
||||
final List<RemoteAlbum> albums;
|
||||
final List<RemoteAlbum> filteredAlbums;
|
||||
|
||||
const RemoteAlbumState({required this.albums, List<RemoteAlbum>? filteredAlbums})
|
||||
: filteredAlbums = filteredAlbums ?? albums;
|
||||
const RemoteAlbumState({required this.albums});
|
||||
|
||||
RemoteAlbumState copyWith({List<RemoteAlbum>? albums, List<RemoteAlbum>? filteredAlbums}) {
|
||||
return RemoteAlbumState(albums: albums ?? this.albums, filteredAlbums: filteredAlbums ?? this.filteredAlbums);
|
||||
RemoteAlbumState copyWith({List<RemoteAlbum>? albums}) {
|
||||
return RemoteAlbumState(albums: albums ?? this.albums);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length})';
|
||||
String toString() => 'RemoteAlbumState(albums: ${albums.length})';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant RemoteAlbumState other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.albums, albums) && listEquals(other.filteredAlbums, filteredAlbums);
|
||||
return listEquals(other.albums, albums);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => albums.hashCode ^ filteredAlbums.hashCode;
|
||||
int get hashCode => albums.hashCode;
|
||||
}
|
||||
|
||||
class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
late RemoteAlbumService _remoteAlbumService;
|
||||
final _logger = Logger('RemoteAlbumNotifier');
|
||||
|
||||
@override
|
||||
RemoteAlbumState build() {
|
||||
_remoteAlbumService = ref.read(remoteAlbumServiceProvider);
|
||||
return const RemoteAlbumState(albums: [], filteredAlbums: []);
|
||||
return const RemoteAlbumState(albums: []);
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> _getAll() async {
|
||||
try {
|
||||
final albums = await _remoteAlbumService.getAll();
|
||||
state = state.copyWith(albums: albums, filteredAlbums: albums);
|
||||
state = state.copyWith(albums: albums);
|
||||
return albums;
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to fetch albums', error, stack);
|
||||
@@ -60,19 +59,21 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
await _getAll();
|
||||
}
|
||||
|
||||
void searchAlbums(String query, String? userId, [QuickFilterMode filterMode = QuickFilterMode.all]) {
|
||||
final filtered = _remoteAlbumService.searchAlbums(state.albums, query, userId, filterMode);
|
||||
|
||||
state = state.copyWith(filteredAlbums: filtered);
|
||||
List<RemoteAlbum> searchAlbums(
|
||||
List<RemoteAlbum> albums,
|
||||
String query,
|
||||
String? userId, [
|
||||
QuickFilterMode filterMode = QuickFilterMode.all,
|
||||
]) {
|
||||
return _remoteAlbumService.searchAlbums(albums, query, userId, filterMode);
|
||||
}
|
||||
|
||||
void clearSearch() {
|
||||
state = state.copyWith(filteredAlbums: state.albums);
|
||||
}
|
||||
|
||||
Future<void> sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) async {
|
||||
final sortedAlbums = await _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
|
||||
state = state.copyWith(filteredAlbums: sortedAlbums);
|
||||
Future<List<RemoteAlbum>> sortAlbums(
|
||||
List<RemoteAlbum> albums,
|
||||
RemoteAlbumSortMode sortMode, {
|
||||
bool isReverse = false,
|
||||
}) async {
|
||||
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);
|
||||
}
|
||||
|
||||
Future<RemoteAlbum?> createAlbum({
|
||||
@@ -83,7 +84,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
try {
|
||||
final album = await _remoteAlbumService.createAlbum(title: title, description: description, assetIds: assetIds);
|
||||
|
||||
state = state.copyWith(albums: [...state.albums, album], filteredAlbums: [...state.filteredAlbums, album]);
|
||||
state = state.copyWith(albums: [...state.albums, album]);
|
||||
|
||||
return album;
|
||||
} catch (error, stack) {
|
||||
@@ -114,11 +115,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
return album.id == albumId ? updatedAlbum : album;
|
||||
}).toList();
|
||||
|
||||
final updatedFilteredAlbums = state.filteredAlbums.map((album) {
|
||||
return album.id == albumId ? updatedAlbum : album;
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
|
||||
state = state.copyWith(albums: updatedAlbums);
|
||||
|
||||
return updatedAlbum;
|
||||
} catch (error, stack) {
|
||||
@@ -139,9 +136,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
await _remoteAlbumService.deleteAlbum(albumId);
|
||||
|
||||
final updatedAlbums = state.albums.where((album) => album.id != albumId).toList();
|
||||
final updatedFilteredAlbums = state.filteredAlbums.where((album) => album.id != albumId).toList();
|
||||
|
||||
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
|
||||
state = state.copyWith(albums: updatedAlbums);
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> getAssets(String albumId) {
|
||||
@@ -164,9 +159,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
await _remoteAlbumService.removeUser(albumId, userId: userId);
|
||||
|
||||
final updatedAlbums = state.albums.where((album) => album.id != albumId).toList();
|
||||
final updatedFilteredAlbums = state.filteredAlbums.where((album) => album.id != albumId).toList();
|
||||
|
||||
state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums);
|
||||
state = state.copyWith(albums: updatedAlbums);
|
||||
}
|
||||
|
||||
Future<void> setActivityStatus(String albumId, bool enabled) {
|
||||
|
||||
@@ -4,16 +4,15 @@ import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/user_agent.dart';
|
||||
|
||||
class ApiService implements Authentication {
|
||||
static final _client = const NetworkRepository().getHttpClient('api');
|
||||
late ApiClient _apiClient;
|
||||
|
||||
late UsersApi usersApi;
|
||||
@@ -51,7 +50,6 @@ class ApiService implements Authentication {
|
||||
|
||||
setEndpoint(String endpoint) {
|
||||
_apiClient = ApiClient(basePath: endpoint, authentication: this);
|
||||
_apiClient.client = _client;
|
||||
_setUserAgentHeader();
|
||||
if (_accessToken != null) {
|
||||
setAccessToken(_accessToken!);
|
||||
@@ -136,11 +134,13 @@ class ApiService implements Authentication {
|
||||
}
|
||||
|
||||
Future<String> _getWellKnownEndpoint(String baseUrl) async {
|
||||
final Client client = Client();
|
||||
|
||||
try {
|
||||
var headers = {"Accept": "application/json"};
|
||||
headers.addAll(getRequestHeaders());
|
||||
|
||||
final res = await _client
|
||||
final res = await client
|
||||
.get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
|
||||
25
mobile/lib/utils/album_filter.utils.dart
Normal file
25
mobile/lib/utils/album_filter.utils.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
|
||||
class AlbumFilter {
|
||||
String? userId;
|
||||
String? query;
|
||||
QuickFilterMode mode;
|
||||
|
||||
AlbumFilter({required this.mode, this.userId, this.query});
|
||||
|
||||
AlbumFilter copyWith({String? userId, String? query, QuickFilterMode? mode}) {
|
||||
return AlbumFilter(userId: userId ?? this.userId, query: query ?? this.query, mode: mode ?? this.mode);
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumSort {
|
||||
RemoteAlbumSortMode mode;
|
||||
bool isReverse;
|
||||
|
||||
AlbumSort({required this.mode, this.isReverse = false});
|
||||
|
||||
AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) {
|
||||
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
|
||||
}
|
||||
}
|
||||
19
mobile/lib/utils/datetime_helpers.dart
Normal file
19
mobile/lib/utils/datetime_helpers.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13
|
||||
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01
|
||||
|
||||
DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch) {
|
||||
if (secondsSinceEpoch == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final milliSeconds = secondsSinceEpoch * 1000;
|
||||
if (milliSeconds < _minMillisecondsSinceEpoch || milliSeconds > _maxMillisecondsSinceEpoch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return DateTime.fromMillisecondsSinceEpoch(milliSeconds);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/models/asset_selection_state.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/upload_dialog.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/drag_sheet.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
|
||||
|
||||
final controlBottomAppBarNotifier = ControlBottomAppBarNotifier();
|
||||
@@ -45,6 +47,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
final bool unfavorite;
|
||||
final bool unarchive;
|
||||
final AssetSelectionState selectionAssetState;
|
||||
final List<Asset> selectedAssets;
|
||||
|
||||
const ControlBottomAppBar({
|
||||
super.key,
|
||||
@@ -64,6 +67,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
this.onRemoveFromAlbum,
|
||||
this.onToggleLocked,
|
||||
this.selectionAssetState = const AssetSelectionState(),
|
||||
this.selectedAssets = const [],
|
||||
this.enabled = true,
|
||||
this.unarchive = false,
|
||||
this.unfavorite = false,
|
||||
@@ -100,6 +104,18 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Show existing AddToAlbumBottomSheet
|
||||
void showAddToAlbumBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))),
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return AddToAlbumBottomSheet(assets: selectedAssets);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void handleRemoteDelete(bool force, Function(bool) deleteCb, {String? alertMsg}) {
|
||||
if (!force) {
|
||||
deleteCb(force);
|
||||
@@ -121,6 +137,15 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
label: "share_link".tr(),
|
||||
onPressed: enabled ? () => onShare(false) : null,
|
||||
),
|
||||
if (!isInLockedView && hasRemote && albums.isNotEmpty)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 100),
|
||||
child: ControlBoxButton(
|
||||
iconData: Icons.photo_album,
|
||||
label: "add_to_album".tr(),
|
||||
onPressed: enabled ? showAddToAlbumBottomSheet : null,
|
||||
),
|
||||
),
|
||||
if (hasRemote && onArchive != null)
|
||||
ControlBoxButton(
|
||||
iconData: unarchive ? Icons.unarchive_outlined : Icons.archive_outlined,
|
||||
|
||||
@@ -440,6 +440,7 @@ class MultiselectGrid extends HookConsumerWidget {
|
||||
onUpload: onUpload,
|
||||
enabled: !processing.value,
|
||||
selectionAssetState: selectionAssetState.value,
|
||||
selectedAssets: selection.value.toList(),
|
||||
onStack: stackEnabled ? onStack : null,
|
||||
onEditTime: editEnabled ? onEditTime : null,
|
||||
onEditLocation: editEnabled ? onEditLocation : null,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
@@ -259,7 +260,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
const AppBarProfileInfoBox(),
|
||||
buildStorageInformation(),
|
||||
const AppBarServerInfo(),
|
||||
if (isReadonlyModeEnabled) buildReadonlyMessage(),
|
||||
if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(),
|
||||
buildAppLogButton(),
|
||||
buildSettingButton(),
|
||||
buildSignOutButton(),
|
||||
|
||||
@@ -121,7 +121,6 @@ class PhotoViewCore extends StatefulWidget {
|
||||
|
||||
class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
with TickerProviderStateMixin, PhotoViewControllerDelegate, HitCornersDetector {
|
||||
Offset? _normalizedPosition;
|
||||
double? _scaleBefore;
|
||||
double? _rotationBefore;
|
||||
|
||||
@@ -154,7 +153,6 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
void onScaleStart(ScaleStartDetails details) {
|
||||
_rotationBefore = controller.rotation;
|
||||
_scaleBefore = scale;
|
||||
_normalizedPosition = details.focalPoint - controller.position;
|
||||
_scaleAnimationController.stop();
|
||||
_positionAnimationController.stop();
|
||||
_rotationAnimationController.stop();
|
||||
@@ -166,8 +164,14 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
};
|
||||
|
||||
void onScaleUpdate(ScaleUpdateDetails details) {
|
||||
final centeredFocalPoint = Offset(
|
||||
details.focalPoint.dx - scaleBoundaries.outerSize.width / 2,
|
||||
details.focalPoint.dy - scaleBoundaries.outerSize.height / 2,
|
||||
);
|
||||
final double newScale = _scaleBefore! * details.scale;
|
||||
Offset delta = details.focalPoint - _normalizedPosition!;
|
||||
final double scaleDelta = newScale / scale;
|
||||
final Offset newPosition =
|
||||
(controller.position + details.focalPointDelta) * scaleDelta - centeredFocalPoint * (scaleDelta - 1);
|
||||
|
||||
updateScaleStateFromNewScale(newScale);
|
||||
|
||||
@@ -176,7 +180,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
|
||||
updateMultiple(
|
||||
scale: newScale,
|
||||
position: panEnabled ? delta : clampPosition(position: delta * details.scale),
|
||||
position: panEnabled ? newPosition : clampPosition(position: newPosition),
|
||||
rotation: rotationEnabled ? _rotationBefore! + details.rotation : null,
|
||||
rotationFocusPoint: rotationEnabled ? details.focalPoint : null,
|
||||
);
|
||||
|
||||
4
mobile/openapi/lib/api/assets_api.dart
generated
4
mobile/openapi/lib/api/assets_api.dart
generated
@@ -18,7 +18,7 @@ class AssetsApi {
|
||||
|
||||
/// checkBulkUpload
|
||||
///
|
||||
/// Checks if assets exist by checksums
|
||||
/// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -52,7 +52,7 @@ class AssetsApi {
|
||||
|
||||
/// checkBulkUpload
|
||||
///
|
||||
/// Checks if assets exist by checksums
|
||||
/// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
38
mobile/openapi/lib/model/smart_search_dto.dart
generated
38
mobile/openapi/lib/model/smart_search_dto.dart
generated
@@ -31,7 +31,8 @@ class SmartSearchDto {
|
||||
this.model,
|
||||
this.page,
|
||||
this.personIds = const [],
|
||||
required this.query,
|
||||
this.query,
|
||||
this.queryAssetId,
|
||||
this.rating,
|
||||
this.size,
|
||||
this.state,
|
||||
@@ -151,7 +152,21 @@ class SmartSearchDto {
|
||||
|
||||
List<String> personIds;
|
||||
|
||||
String query;
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? query;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? queryAssetId;
|
||||
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
@@ -278,6 +293,7 @@ class SmartSearchDto {
|
||||
other.page == page &&
|
||||
_deepEquality.equals(other.personIds, personIds) &&
|
||||
other.query == query &&
|
||||
other.queryAssetId == queryAssetId &&
|
||||
other.rating == rating &&
|
||||
other.size == size &&
|
||||
other.state == state &&
|
||||
@@ -314,7 +330,8 @@ class SmartSearchDto {
|
||||
(model == null ? 0 : model!.hashCode) +
|
||||
(page == null ? 0 : page!.hashCode) +
|
||||
(personIds.hashCode) +
|
||||
(query.hashCode) +
|
||||
(query == null ? 0 : query!.hashCode) +
|
||||
(queryAssetId == null ? 0 : queryAssetId!.hashCode) +
|
||||
(rating == null ? 0 : rating!.hashCode) +
|
||||
(size == null ? 0 : size!.hashCode) +
|
||||
(state == null ? 0 : state!.hashCode) +
|
||||
@@ -331,7 +348,7 @@ class SmartSearchDto {
|
||||
(withExif == null ? 0 : withExif!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
|
||||
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -417,7 +434,16 @@ class SmartSearchDto {
|
||||
// json[r'page'] = null;
|
||||
}
|
||||
json[r'personIds'] = this.personIds;
|
||||
if (this.query != null) {
|
||||
json[r'query'] = this.query;
|
||||
} else {
|
||||
// json[r'query'] = null;
|
||||
}
|
||||
if (this.queryAssetId != null) {
|
||||
json[r'queryAssetId'] = this.queryAssetId;
|
||||
} else {
|
||||
// json[r'queryAssetId'] = null;
|
||||
}
|
||||
if (this.rating != null) {
|
||||
json[r'rating'] = this.rating;
|
||||
} else {
|
||||
@@ -522,7 +548,8 @@ class SmartSearchDto {
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
query: mapValueOfType<String>(json, r'query')!,
|
||||
query: mapValueOfType<String>(json, r'query'),
|
||||
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
@@ -586,7 +613,6 @@ class SmartSearchDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'query',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
3
mobile/openapi/lib/model/sync_entity_type.dart
generated
3
mobile/openapi/lib/model/sync_entity_type.dart
generated
@@ -69,6 +69,7 @@ class SyncEntityType {
|
||||
static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1');
|
||||
static const syncAckV1 = SyncEntityType._(r'SyncAckV1');
|
||||
static const syncResetV1 = SyncEntityType._(r'SyncResetV1');
|
||||
static const syncCompleteV1 = SyncEntityType._(r'SyncCompleteV1');
|
||||
|
||||
/// List of all possible values in this [enum][SyncEntityType].
|
||||
static const values = <SyncEntityType>[
|
||||
@@ -118,6 +119,7 @@ class SyncEntityType {
|
||||
userMetadataDeleteV1,
|
||||
syncAckV1,
|
||||
syncResetV1,
|
||||
syncCompleteV1,
|
||||
];
|
||||
|
||||
static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value);
|
||||
@@ -202,6 +204,7 @@ class SyncEntityTypeTypeTransformer {
|
||||
case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1;
|
||||
case r'SyncAckV1': return SyncEntityType.syncAckV1;
|
||||
case r'SyncResetV1': return SyncEntityType.syncResetV1;
|
||||
case r'SyncCompleteV1': return SyncEntityType.syncCompleteV1;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
@@ -13,13 +13,9 @@ import 'package:pigeon/pigeon.dart';
|
||||
)
|
||||
@HostApi()
|
||||
abstract class BackgroundWorkerFgHostApi {
|
||||
void enableSyncWorker();
|
||||
void enable();
|
||||
|
||||
// Enables the background upload service with the given callback handle
|
||||
void enableUploadWorker(int callbackHandle);
|
||||
|
||||
// Disables the background upload service
|
||||
void disableUploadWorker();
|
||||
void disable();
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
@@ -27,14 +23,13 @@ abstract class BackgroundWorkerBgHostApi {
|
||||
// Called from the background flutter engine when it has bootstrapped and established the
|
||||
// required platform channels to notify the native side to start the background upload
|
||||
void onInitialized();
|
||||
|
||||
// Called from the background flutter engine to request the native side to cleanup
|
||||
void close();
|
||||
}
|
||||
|
||||
@FlutterApi()
|
||||
abstract class BackgroundWorkerFlutterApi {
|
||||
// Android & iOS: Called when the local sync is triggered
|
||||
@async
|
||||
void onLocalSync(int? maxSeconds);
|
||||
|
||||
// iOS Only: Called when the iOS background upload is triggered
|
||||
@async
|
||||
void onIosUpload(bool isRefresh, int? maxSeconds);
|
||||
|
||||
@@ -337,14 +337,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cronet_http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cronet_http
|
||||
sha256: "1b99ad5ae81aa9d2f12900e5f17d3681f3828629bb7f7fe7ad88076a34209840"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
crop_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -377,14 +369,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_http
|
||||
sha256: "72187f715837290a63479a5b0ae709f4fedad0ed6bd0441c275eceaa02d5abae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
custom_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -915,10 +899,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
version: "1.3.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -935,14 +919,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
http_profile:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_profile
|
||||
sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1068,14 +1044,6 @@ packages:
|
||||
url: "https://github.com/immich-app/isar"
|
||||
source: git
|
||||
version: "3.1.8"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.2"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1269,14 +1237,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "9f034ba1eeca53ddb339bc8f4813cb07336a849cd735559b60cdc068ecce2dc7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.1.0"
|
||||
octo_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -90,8 +90,6 @@ dependencies:
|
||||
# DB
|
||||
drift: ^2.23.1
|
||||
drift_flutter: ^0.2.4
|
||||
cronet_http: ^1.5.0
|
||||
cupertino_http: ^2.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
58
mobile/test/modules/utils/datetime_helpers_test.dart
Normal file
58
mobile/test/modules/utils/datetime_helpers_test.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('tryFromSecondsSinceEpoch', () {
|
||||
test('returns null for null input', () {
|
||||
final result = tryFromSecondsSinceEpoch(null);
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('returns null for value below minimum allowed range', () {
|
||||
// _minMillisecondsSinceEpoch = -62135596800000
|
||||
final seconds = -62135596800000 ~/ 1000 - 1; // One second before min allowed
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('returns null for value above maximum allowed range', () {
|
||||
// _maxMillisecondsSinceEpoch = 8640000000000000
|
||||
final seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('returns correct DateTime for minimum allowed value', () {
|
||||
final seconds = -62135596800000 ~/ 1000; // Minimum allowed timestamp
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, DateTime.fromMillisecondsSinceEpoch(-62135596800000));
|
||||
});
|
||||
|
||||
test('returns correct DateTime for maximum allowed value', () {
|
||||
final seconds = 8640000000000000 ~/ 1000; // Maximum allowed timestamp
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, DateTime.fromMillisecondsSinceEpoch(8640000000000000));
|
||||
});
|
||||
|
||||
test('returns correct DateTime for negative timestamp', () {
|
||||
final seconds = -1577836800; // Dec 31, 1919 (pre-epoch)
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, DateTime.fromMillisecondsSinceEpoch(-1577836800 * 1000));
|
||||
});
|
||||
|
||||
test('returns correct DateTime for zero timestamp', () {
|
||||
final seconds = 0; // Jan 1, 1970 (epoch)
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, DateTime.fromMillisecondsSinceEpoch(0));
|
||||
});
|
||||
|
||||
test('returns correct DateTime for recent timestamp', () {
|
||||
final now = DateTime.now();
|
||||
final seconds = now.millisecondsSinceEpoch ~/ 1000;
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result?.year, now.year);
|
||||
expect(result?.month, now.month);
|
||||
expect(result?.day, now.day);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@ function dart {
|
||||
patch --no-backup-if-mismatch -u api.mustache <api.mustache.patch
|
||||
|
||||
cd ../../
|
||||
pnpx @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile
|
||||
pnpm dlx @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile
|
||||
|
||||
# Post generate patches
|
||||
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch
|
||||
@@ -27,7 +27,7 @@ function dart {
|
||||
}
|
||||
|
||||
function typescript {
|
||||
pnpx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
|
||||
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
|
||||
pnpm --filter @immich/sdk install --frozen-lockfile
|
||||
pnpm --filter @immich/sdk build
|
||||
}
|
||||
@@ -35,8 +35,8 @@ function typescript {
|
||||
# requires server to be built
|
||||
(
|
||||
cd ..
|
||||
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich build
|
||||
pnpm --filter immich sync:open-api
|
||||
SHARP_IGNORE_GLOBAL_LIBVIPS=true mise run server:build
|
||||
mise run server:open-api
|
||||
)
|
||||
|
||||
if [[ $1 == 'dart' ]]; then
|
||||
|
||||
@@ -1855,7 +1855,7 @@
|
||||
},
|
||||
"/assets/bulk-upload-check": {
|
||||
"post": {
|
||||
"description": "Checks if assets exist by checksums",
|
||||
"description": "Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.",
|
||||
"operationId": "checkBulkUpload",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
@@ -1894,7 +1894,8 @@
|
||||
"summary": "checkBulkUpload",
|
||||
"tags": [
|
||||
"Assets"
|
||||
]
|
||||
],
|
||||
"x-immich-permission": "asset.upload"
|
||||
}
|
||||
},
|
||||
"/assets/device/{deviceId}": {
|
||||
@@ -14570,6 +14571,10 @@
|
||||
"query": {
|
||||
"type": "string"
|
||||
},
|
||||
"queryAssetId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"rating": {
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
@@ -14637,9 +14642,6 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SourceType": {
|
||||
@@ -15415,6 +15417,10 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncCompleteV1": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"SyncEntityType": {
|
||||
"enum": [
|
||||
"AuthUserV1",
|
||||
@@ -15462,7 +15468,8 @@
|
||||
"UserMetadataV1",
|
||||
"UserMetadataDeleteV1",
|
||||
"SyncAckV1",
|
||||
"SyncResetV1"
|
||||
"SyncResetV1",
|
||||
"SyncCompleteV1"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
22.18.0
|
||||
@@ -11,9 +11,6 @@
|
||||
"default": "./build/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
@@ -26,8 +23,5 @@
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/immich-app/immich.git",
|
||||
"directory": "open-api/typescript-sdk"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,7 +1014,8 @@ export type SmartSearchDto = {
|
||||
model?: string | null;
|
||||
page?: number;
|
||||
personIds?: string[];
|
||||
query: string;
|
||||
query?: string;
|
||||
queryAssetId?: string;
|
||||
rating?: number;
|
||||
size?: number;
|
||||
state?: string | null;
|
||||
@@ -4921,7 +4922,8 @@ export enum SyncEntityType {
|
||||
UserMetadataV1 = "UserMetadataV1",
|
||||
UserMetadataDeleteV1 = "UserMetadataDeleteV1",
|
||||
SyncAckV1 = "SyncAckV1",
|
||||
SyncResetV1 = "SyncResetV1"
|
||||
SyncResetV1 = "SyncResetV1",
|
||||
SyncCompleteV1 = "SyncCompleteV1"
|
||||
}
|
||||
export enum SyncRequestType {
|
||||
AlbumsV1 = "AlbumsV1",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
22.18.0
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS dev
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
@@ -77,7 +77,7 @@ RUN apt-get update \
|
||||
RUN dart --disable-analytics
|
||||
|
||||
# production-builder-base image
|
||||
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS prod-builder-base
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS prod-builder-base
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp
|
||||
@@ -115,7 +115,7 @@ RUN pnpm --filter @immich/sdk --filter @immich/cli --frozen-lockfile install &&
|
||||
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
|
||||
|
||||
# prod base image
|
||||
FROM ghcr.io/immich-app/base-server-prod:202508191104@sha256:4cce4119f5555fce5e383b681e4feea31956ceadb94cafcbcbbae2c7b94a1b62
|
||||
FROM ghcr.io/immich-app/base-server-prod:202509021104@sha256:84f3727cff75c623f79236cdd9a2b72c84f7665057f474851016f702c67157af
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -5,34 +5,6 @@
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"start": "npm run start:dev",
|
||||
"nest": "nest",
|
||||
"start:dev": "nest start --watch --",
|
||||
"start:debug": "nest start --debug 0.0.0.0:9230 --watch --",
|
||||
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"check": "tsc --noEmit",
|
||||
"check:code": "npm run format && npm run lint && npm run check",
|
||||
"check:all": "npm run check:code && npm run test:cov",
|
||||
"test": "vitest --config test/vitest.config.mjs",
|
||||
"test:cov": "vitest --config test/vitest.config.mjs --coverage",
|
||||
"test:medium": "vitest --config test/vitest.config.medium.mjs",
|
||||
"typeorm": "typeorm",
|
||||
"lifecycle": "node ./dist/utils/lifecycle.js",
|
||||
"migrations:debug": "node ./dist/bin/migrations.js debug",
|
||||
"migrations:generate": "node ./dist/bin/migrations.js generate",
|
||||
"migrations:create": "node ./dist/bin/migrations.js create",
|
||||
"migrations:run": "node ./dist/bin/migrations.js run",
|
||||
"schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'",
|
||||
"schema:reset": "npm run schema:drop && npm run migrations:run",
|
||||
"sync:open-api": "node ./dist/bin/sync-open-api.js",
|
||||
"sync:sql": "node ./dist/bin/sync-sql.js",
|
||||
"email:dev": "email dev -p 3050 --dir src/emails"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
@@ -172,9 +144,6 @@
|
||||
"vite-tsconfig-paths": "^5.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.18.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.34.2"
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ export class AssetMediaController {
|
||||
* Checks if assets exist by checksums
|
||||
*/
|
||||
@Post('bulk-upload-check')
|
||||
@Authenticated()
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@ApiOperation({
|
||||
summary: 'checkBulkUpload',
|
||||
description: 'Checks if assets exist by checksums',
|
||||
|
||||
@@ -128,12 +128,6 @@ describe(SearchController.name, () => {
|
||||
await request(ctx.getHttpServer()).post('/search/smart');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a query', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/smart').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['query should not be empty', 'query must be a string']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /search/explore', () => {
|
||||
|
||||
@@ -199,7 +199,12 @@ export class StatisticsSearchDto extends BaseSearchDto {
|
||||
export class SmartSearchDto extends BaseSearchWithResultsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
query!: string;
|
||||
@Optional()
|
||||
query?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@Optional()
|
||||
queryAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -336,6 +336,9 @@ export class SyncAckV1 {}
|
||||
@ExtraModel()
|
||||
export class SyncResetV1 {}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncCompleteV1 {}
|
||||
|
||||
export type SyncItem = {
|
||||
[SyncEntityType.AuthUserV1]: SyncAuthUserV1;
|
||||
[SyncEntityType.UserV1]: SyncUserV1;
|
||||
@@ -382,6 +385,7 @@ export type SyncItem = {
|
||||
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
|
||||
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
|
||||
[SyncEntityType.SyncAckV1]: SyncAckV1;
|
||||
[SyncEntityType.SyncCompleteV1]: SyncCompleteV1;
|
||||
[SyncEntityType.SyncResetV1]: SyncResetV1;
|
||||
};
|
||||
|
||||
|
||||
@@ -530,6 +530,7 @@ export enum JobName {
|
||||
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
|
||||
|
||||
AuditLogCleanup = 'AuditLogCleanup',
|
||||
AuditTableCleanup = 'AuditTableCleanup',
|
||||
|
||||
DatabaseBackup = 'DatabaseBackup',
|
||||
|
||||
@@ -570,8 +571,7 @@ export enum JobName {
|
||||
SendMail = 'SendMail',
|
||||
|
||||
SidecarQueueAll = 'SidecarQueueAll',
|
||||
SidecarDiscovery = 'SidecarDiscovery',
|
||||
SidecarSync = 'SidecarSync',
|
||||
SidecarCheck = 'SidecarCheck',
|
||||
SidecarWrite = 'SidecarWrite',
|
||||
|
||||
SmartSearchQueueAll = 'SmartSearchQueueAll',
|
||||
@@ -708,6 +708,7 @@ export enum SyncEntityType {
|
||||
|
||||
SyncAckV1 = 'SyncAckV1',
|
||||
SyncResetV1 = 'SyncResetV1',
|
||||
SyncCompleteV1 = 'SyncCompleteV1',
|
||||
}
|
||||
|
||||
export enum NotificationLevel {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
||||
import { asRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||
|
||||
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
|
||||
const file = files[property]?.[0];
|
||||
@@ -99,18 +99,21 @@ export class FileUploadInterceptor implements NestInterceptor {
|
||||
}
|
||||
|
||||
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||
return callbackify(() => this.assetService.canUploadFile(asRequest(request, file)), callback);
|
||||
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
|
||||
}
|
||||
|
||||
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFilename(asRequest(request, file)),
|
||||
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(() => this.assetService.getUploadFolder(asRequest(request, file)), callback as Callback<string>);
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
||||
|
||||
@@ -43,6 +43,18 @@ where
|
||||
limit
|
||||
$2
|
||||
|
||||
-- AssetJobRepository.getForSidecarCheckJob
|
||||
select
|
||||
"id",
|
||||
"sidecarPath",
|
||||
"originalPath"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1::uuid
|
||||
limit
|
||||
$2
|
||||
|
||||
-- AssetJobRepository.streamForThumbnailJob
|
||||
select
|
||||
"asset"."id",
|
||||
|
||||
@@ -123,6 +123,14 @@ offset
|
||||
$8
|
||||
commit
|
||||
|
||||
-- SearchRepository.getEmbedding
|
||||
select
|
||||
*
|
||||
from
|
||||
"smart_search"
|
||||
where
|
||||
"assetId" = $1
|
||||
|
||||
-- SearchRepository.searchFaces
|
||||
begin
|
||||
set
|
||||
|
||||
@@ -957,7 +957,7 @@ where
|
||||
order by
|
||||
"stack"."updateId" asc
|
||||
|
||||
-- SyncRepository.people.getDeletes
|
||||
-- SyncRepository.person.getDeletes
|
||||
select
|
||||
"id",
|
||||
"personId"
|
||||
@@ -970,7 +970,7 @@ where
|
||||
order by
|
||||
"person_audit"."id" asc
|
||||
|
||||
-- SyncRepository.people.getUpserts
|
||||
-- SyncRepository.person.getUpserts
|
||||
select
|
||||
"id",
|
||||
"createdAt",
|
||||
|
||||
@@ -39,10 +39,8 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.select((eb) => [
|
||||
'id',
|
||||
'sidecarPath',
|
||||
'originalPath',
|
||||
.select(['id', 'sidecarPath', 'originalPath'])
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('tag')
|
||||
@@ -50,7 +48,17 @@ export class AssetJobRepository {
|
||||
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId')
|
||||
.whereRef('asset.id', '=', 'tag_asset.assetsId'),
|
||||
).as('tags'),
|
||||
])
|
||||
)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForSidecarCheckJob(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.select(['id', 'sidecarPath', 'originalPath'])
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -293,6 +293,13 @@ export class SearchRepository {
|
||||
});
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
async getEmbedding(assetId: string) {
|
||||
return this.db.selectFrom('smart_search').selectAll().where('assetId', '=', assetId).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
@@ -62,7 +62,7 @@ export class SyncRepository {
|
||||
partnerAsset: PartnerAssetsSync;
|
||||
partnerAssetExif: PartnerAssetExifsSync;
|
||||
partnerStack: PartnerStackSync;
|
||||
people: PersonSync;
|
||||
person: PersonSync;
|
||||
stack: StackSync;
|
||||
user: UserSync;
|
||||
userMetadata: UserMetadataSync;
|
||||
@@ -84,7 +84,7 @@ export class SyncRepository {
|
||||
this.partnerAsset = new PartnerAssetsSync(this.db);
|
||||
this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
|
||||
this.partnerStack = new PartnerStackSync(this.db);
|
||||
this.people = new PersonSync(this.db);
|
||||
this.person = new PersonSync(this.db);
|
||||
this.stack = new StackSync(this.db);
|
||||
this.user = new UserSync(this.db);
|
||||
this.userMetadata = new UserMetadataSync(this.db);
|
||||
@@ -117,6 +117,15 @@ class BaseSync {
|
||||
.orderBy(idRef, 'asc');
|
||||
}
|
||||
|
||||
protected auditCleanup<T extends keyof DB>(t: T, days: number) {
|
||||
const { table, ref } = this.db.dynamic;
|
||||
|
||||
return this.db
|
||||
.deleteFrom(table(t).as(t))
|
||||
.where(ref(`${t}.deletedAt`), '<', sql.raw(`now() - interval '${days} days'`))
|
||||
.execute();
|
||||
}
|
||||
|
||||
protected upsertQuery<T extends keyof DB>(t: T, { nowId, ack }: SyncQueryOptions) {
|
||||
const { table, ref } = this.db.dynamic;
|
||||
const updateIdRef = ref(`${t}.updateId`);
|
||||
@@ -150,6 +159,10 @@ class AlbumSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('album_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
const userId = options.userId;
|
||||
@@ -286,6 +299,10 @@ class AlbumToAssetSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('album_asset_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
const userId = options.userId;
|
||||
@@ -334,6 +351,10 @@ class AlbumUserSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('album_user_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
const userId = options.userId;
|
||||
@@ -371,6 +392,10 @@ class AssetSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('asset_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('asset', options)
|
||||
@@ -400,6 +425,10 @@ class PersonSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('person_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('person', options)
|
||||
@@ -431,6 +460,10 @@ class AssetFaceSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('asset_face_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('asset_face', options)
|
||||
@@ -473,6 +506,10 @@ class MemorySync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('memory_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('memory', options)
|
||||
@@ -505,6 +542,10 @@ class MemoryToAssetSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('memory_asset_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('memory_asset', options)
|
||||
@@ -537,6 +578,10 @@ class PartnerSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('partner_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
const userId = options.userId;
|
||||
@@ -616,6 +661,10 @@ class StackSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('stack_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('stack', options)
|
||||
@@ -664,6 +713,10 @@ class UserSync extends BaseSync {
|
||||
return this.auditQuery('user_audit', options).select(['id', 'userId']).stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('user_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('user', options).select(columns.syncUser).stream();
|
||||
@@ -679,6 +732,10 @@ class UserMetadataSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('user_metadata_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('user_metadata', options)
|
||||
@@ -698,6 +755,10 @@ class AssetMetadataSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('asset_metadata_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
|
||||
getUpserts(options: SyncQueryOptions, userId: string) {
|
||||
return this.upsertQuery('asset_metadata', options)
|
||||
|
||||
@@ -166,6 +166,7 @@ export interface DB {
|
||||
api_key: ApiKeyTable;
|
||||
|
||||
asset: AssetTable;
|
||||
asset_audit: AssetAuditTable;
|
||||
asset_exif: AssetExifTable;
|
||||
asset_face: AssetFaceTable;
|
||||
asset_face_audit: AssetFaceAuditTable;
|
||||
@@ -173,7 +174,6 @@ export interface DB {
|
||||
asset_metadata: AssetMetadataTable;
|
||||
asset_metadata_audit: AssetMetadataAuditTable;
|
||||
asset_job_status: AssetJobStatusTable;
|
||||
asset_audit: AssetAuditTable;
|
||||
|
||||
audit: AuditTable;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('memory_asset_audit')
|
||||
export class MemoryAssetAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: string;
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => MemoryTable, { type: 'uuid', onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
memoryId!: string;
|
||||
@@ -14,5 +14,5 @@ export class MemoryAssetAuditTable {
|
||||
assetId!: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||
deletedAt!: Date;
|
||||
deletedAt!: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
const uploadFile = {
|
||||
nullAuth: {
|
||||
auth: null,
|
||||
body: {},
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
@@ -37,6 +38,7 @@ const uploadFile = {
|
||||
filename: (fieldName: UploadFieldName, filename: string) => {
|
||||
return {
|
||||
auth: authStub.admin,
|
||||
body: {},
|
||||
fieldName,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
@@ -897,7 +899,10 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('onUploadError', () => {
|
||||
it('should queue a job to delete the uploaded file', async () => {
|
||||
const request = { user: authStub.user1 } as AuthRequest;
|
||||
const request = {
|
||||
body: {},
|
||||
user: authStub.user1,
|
||||
} as AuthRequest;
|
||||
|
||||
const file = {
|
||||
fieldname: UploadFieldName.ASSET_DATA,
|
||||
|
||||
@@ -24,20 +24,14 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { UploadFile } from 'src/types';
|
||||
import { UploadFile, UploadRequest } from 'src/types';
|
||||
import { requireUploadAccess } from 'src/utils/access';
|
||||
import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { asUploadRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
interface UploadRequest {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
file: UploadFile;
|
||||
}
|
||||
|
||||
export interface AssetMediaRedirectResponse {
|
||||
targetSize: AssetMediaSize | 'original';
|
||||
}
|
||||
@@ -89,15 +83,15 @@ export class AssetMediaService extends BaseService {
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
|
||||
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
|
||||
requireUploadAccess(auth);
|
||||
|
||||
const originalExtension = extname(file.originalName);
|
||||
const extension = extname(body.filename || file.originalName);
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: originalExtension,
|
||||
[UploadFieldName.ASSET_DATA]: extension,
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: originalExtension,
|
||||
[UploadFieldName.PROFILE_DATA]: extension,
|
||||
};
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
@@ -117,8 +111,8 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
async onUploadError(request: AuthRequest, file: Express.Multer.File) {
|
||||
const uploadFilename = this.getUploadFilename(asRequest(request, file));
|
||||
const uploadFolder = this.getUploadFolder(asRequest(request, file));
|
||||
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
|
||||
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
|
||||
const uploadPath = `${uploadFolder}/${uploadFilename}`;
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
|
||||
|
||||
@@ -42,6 +42,7 @@ describe(JobService.name, () => {
|
||||
{ name: JobName.PersonCleanup },
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
{ name: JobName.AuditLogCleanup },
|
||||
{ name: JobName.MemoryGenerate },
|
||||
{ name: JobName.UserSyncUsage },
|
||||
@@ -238,11 +239,11 @@ describe(JobService.name, () => {
|
||||
|
||||
const tests: Array<{ item: JobItem; jobs: JobName[]; stub?: any }> = [
|
||||
{
|
||||
item: { name: JobName.SidecarSync, data: { id: 'asset-1' } },
|
||||
item: { name: JobName.SidecarCheck, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.AssetExtractMetadata],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.SidecarDiscovery, data: { id: 'asset-1' } },
|
||||
item: { name: JobName.SidecarCheck, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.AssetExtractMetadata],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -281,6 +281,7 @@ export class JobService extends BaseService {
|
||||
{ name: JobName.PersonCleanup },
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
{ name: JobName.AuditLogCleanup },
|
||||
);
|
||||
}
|
||||
@@ -309,8 +310,7 @@ export class JobService extends BaseService {
|
||||
*/
|
||||
private async onDone(item: JobItem) {
|
||||
switch (item.name) {
|
||||
case JobName.SidecarSync:
|
||||
case JobName.SidecarDiscovery: {
|
||||
case JobName.SidecarCheck: {
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: item.data });
|
||||
break;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user