Merge branch 'main' into fix/mobile-unawaited-futures
This commit is contained in:
@@ -5,8 +5,7 @@
|
|||||||
"immich-server",
|
"immich-server",
|
||||||
"redis",
|
"redis",
|
||||||
"database",
|
"database",
|
||||||
"immich-machine-learning",
|
"immich-machine-learning"
|
||||||
"init"
|
|
||||||
],
|
],
|
||||||
"dockerComposeFile": [
|
"dockerComposeFile": [
|
||||||
"../docker/docker-compose.dev.yml",
|
"../docker/docker-compose.dev.yml",
|
||||||
|
|||||||
@@ -32,24 +32,18 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
mobile:
|
mobile:
|
||||||
- 'mobile/**'
|
- 'mobile/**'
|
||||||
workflow:
|
force-filters: |
|
||||||
- '.github/workflows/build-mobile.yml'
|
- '.github/workflows/build-mobile.yml'
|
||||||
- name: Check if we should force jobs to run
|
force-events: 'workflow_call,workflow_dispatch'
|
||||||
id: should_force
|
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
build-sign-android:
|
build-sign-android:
|
||||||
name: Build and sign Android
|
name: Build and sign Android
|
||||||
@@ -57,7 +51,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
# Skip when PR from a fork
|
# Skip when PR from a fork
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }}
|
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
runs-on: mich
|
runs-on: mich
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -20,15 +20,11 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
server:
|
server:
|
||||||
@@ -38,14 +34,11 @@ jobs:
|
|||||||
- 'i18n/**'
|
- 'i18n/**'
|
||||||
machine-learning:
|
machine-learning:
|
||||||
- 'machine-learning/**'
|
- 'machine-learning/**'
|
||||||
workflow:
|
force-filters: |
|
||||||
- '.github/workflows/docker.yml'
|
- '.github/workflows/docker.yml'
|
||||||
- '.github/workflows/multi-runner-build.yml'
|
- '.github/workflows/multi-runner-build.yml'
|
||||||
- '.github/actions/image-build'
|
- '.github/actions/image-build'
|
||||||
|
force-events: 'workflow_dispatch,release'
|
||||||
- name: Check if we should force jobs to run
|
|
||||||
id: should_force
|
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
retag_ml:
|
retag_ml:
|
||||||
name: Re-Tag ML
|
name: Re-Tag ML
|
||||||
@@ -53,7 +46,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
if: ${{ needs.pre-job.outputs.should_run_ml == 'false' && !github.event.pull_request.head.repo.fork }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -82,7 +75,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'false' && !github.event.pull_request.head.repo.fork }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -108,7 +101,7 @@ jobs:
|
|||||||
machine-learning:
|
machine-learning:
|
||||||
name: Build and Push ML
|
name: Build and Push ML
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -153,7 +146,7 @@ jobs:
|
|||||||
server:
|
server:
|
||||||
name: Build and Push Server
|
name: Build and Push Server
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -18,32 +18,28 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.found_paths.outputs.open-api == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
docs:
|
docs:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
workflow:
|
|
||||||
- '.github/workflows/docs-build.yml'
|
|
||||||
open-api:
|
open-api:
|
||||||
- 'open-api/immich-openapi-specs.json'
|
- 'open-api/immich-openapi-specs.json'
|
||||||
- name: Check if we should force jobs to run
|
force-filters: |
|
||||||
id: should_force
|
- '.github/workflows/docs-build.yml'
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' || github.ref_name == 'main' }}" >> "$GITHUB_OUTPUT"
|
force-events: 'release'
|
||||||
|
force-branches: 'main'
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Docs Build
|
name: Docs Build
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).docs == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
|||||||
@@ -17,28 +17,23 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
mobile:
|
mobile:
|
||||||
- 'mobile/**'
|
- 'mobile/**'
|
||||||
workflow:
|
force-filters: |
|
||||||
- '.github/workflows/static_analysis.yml'
|
- '.github/workflows/static_analysis.yml'
|
||||||
- name: Check if we should force jobs to run
|
force-events: 'workflow_dispatch,release'
|
||||||
id: should_force
|
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
mobile-dart-analyze:
|
mobile-dart-analyze:
|
||||||
name: Run Dart Code Analysis
|
name: Run Dart Code Analysis
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
+21
-34
@@ -14,23 +14,11 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run_i18n: ${{ steps.found_paths.outputs.i18n == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_e2e: ${{ steps.found_paths.outputs.e2e == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_mobile: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_e2e_web: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_e2e_server_cli: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.server == 'true' || steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_.github: ${{ steps.found_paths.outputs['.github'] == 'true' || steps.should_force.outputs.should_force == 'true' }} # redundant to have should_force but if someone changes the trigger then this won't have to be changed
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
i18n:
|
i18n:
|
||||||
@@ -50,17 +38,16 @@ jobs:
|
|||||||
- 'mobile/**'
|
- 'mobile/**'
|
||||||
machine-learning:
|
machine-learning:
|
||||||
- 'machine-learning/**'
|
- 'machine-learning/**'
|
||||||
workflow:
|
|
||||||
- '.github/workflows/test.yml'
|
|
||||||
.github:
|
.github:
|
||||||
- '.github/**'
|
- '.github/**'
|
||||||
- name: Check if we should force jobs to run
|
force-filters: |
|
||||||
id: should_force
|
- '.github/workflows/test.yml'
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
|
force-events: 'workflow_dispatch'
|
||||||
|
|
||||||
server-unit-tests:
|
server-unit-tests:
|
||||||
name: Test & Lint Server
|
name: Test & Lint Server
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -97,7 +84,7 @@ jobs:
|
|||||||
cli-unit-tests:
|
cli-unit-tests:
|
||||||
name: Unit Test CLI
|
name: Unit Test CLI
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -137,7 +124,7 @@ jobs:
|
|||||||
cli-unit-tests-win:
|
cli-unit-tests-win:
|
||||||
name: Unit Test CLI (Windows)
|
name: Unit Test CLI (Windows)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -172,7 +159,7 @@ jobs:
|
|||||||
web-lint:
|
web-lint:
|
||||||
name: Lint Web
|
name: Lint Web
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||||
runs-on: mich
|
runs-on: mich
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -209,7 +196,7 @@ jobs:
|
|||||||
web-unit-tests:
|
web-unit-tests:
|
||||||
name: Test Web
|
name: Test Web
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -243,7 +230,7 @@ jobs:
|
|||||||
i18n-tests:
|
i18n-tests:
|
||||||
name: Test i18n
|
name: Test i18n
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_i18n == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -281,7 +268,7 @@ jobs:
|
|||||||
e2e-tests-lint:
|
e2e-tests-lint:
|
||||||
name: End-to-End Lint
|
name: End-to-End Lint
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_e2e == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -320,7 +307,7 @@ jobs:
|
|||||||
server-medium-tests:
|
server-medium-tests:
|
||||||
name: Medium Tests (Server)
|
name: Medium Tests (Server)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -348,7 +335,7 @@ jobs:
|
|||||||
e2e-tests-server-cli:
|
e2e-tests-server-cli:
|
||||||
name: End-to-End Tests (Server & CLI)
|
name: End-to-End Tests (Server & CLI)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -396,7 +383,7 @@ jobs:
|
|||||||
e2e-tests-web:
|
e2e-tests-web:
|
||||||
name: End-to-End Tests (Web)
|
name: End-to-End Tests (Web)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -449,7 +436,7 @@ jobs:
|
|||||||
mobile-unit-tests:
|
mobile-unit-tests:
|
||||||
name: Unit Test Mobile
|
name: Unit Test Mobile
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -471,7 +458,7 @@ jobs:
|
|||||||
ml-unit-tests:
|
ml-unit-tests:
|
||||||
name: Unit Test ML
|
name: Unit Test ML
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -507,7 +494,7 @@ jobs:
|
|||||||
github-files-formatting:
|
github-files-formatting:
|
||||||
name: .github Files Formatting
|
name: .github Files Formatting
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs['should_run_.github'] == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run)['.github'] == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -21,25 +21,24 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run: ${{ steps.found_paths.outputs.i18n == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
i18n:
|
i18n:
|
||||||
- 'i18n/!(en)**\.json'
|
- 'i18n/!(en)**\.json'
|
||||||
|
exclude-branches: 'chore/translations'
|
||||||
|
skip-force-logic: 'true'
|
||||||
|
|
||||||
enforce-lock:
|
enforce-lock:
|
||||||
name: Check Weblate Lock
|
name: Check Weblate Lock
|
||||||
needs: [pre-job]
|
needs: [pre-job]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions: {}
|
permissions: {}
|
||||||
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||||
steps:
|
steps:
|
||||||
- name: Bot review status
|
- name: Bot review status
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
/web/ @danieldietzler
|
/web/ @danieldietzler
|
||||||
/machine-learning/ @mertalev
|
/machine-learning/ @mertalev
|
||||||
/e2e/ @danieldietzler
|
/e2e/ @danieldietzler
|
||||||
|
/mobile/ @shenlong-tanwen
|
||||||
|
|||||||
@@ -533,6 +533,7 @@
|
|||||||
"background_backup_running_error": "Background backup is currently running, cannot start manual backup",
|
"background_backup_running_error": "Background backup is currently running, cannot start manual backup",
|
||||||
"background_location_permission": "Background location permission",
|
"background_location_permission": "Background location permission",
|
||||||
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
|
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
|
||||||
|
"background_options": "Background Options",
|
||||||
"backup": "Backup",
|
"backup": "Backup",
|
||||||
"backup_album_selection_page_albums_device": "Albums on device ({count})",
|
"backup_album_selection_page_albums_device": "Albums on device ({count})",
|
||||||
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
|
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
|
||||||
@@ -540,6 +541,7 @@
|
|||||||
"backup_album_selection_page_select_albums": "Select albums",
|
"backup_album_selection_page_select_albums": "Select albums",
|
||||||
"backup_album_selection_page_selection_info": "Selection Info",
|
"backup_album_selection_page_selection_info": "Selection Info",
|
||||||
"backup_album_selection_page_total_assets": "Total unique assets",
|
"backup_album_selection_page_total_assets": "Total unique assets",
|
||||||
|
"backup_albums_sync": "Backup albums synchronization",
|
||||||
"backup_all": "All",
|
"backup_all": "All",
|
||||||
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
||||||
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
|
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
|
||||||
@@ -656,6 +658,8 @@
|
|||||||
"change_pin_code": "Change PIN code",
|
"change_pin_code": "Change PIN code",
|
||||||
"change_your_password": "Change your password",
|
"change_your_password": "Change your password",
|
||||||
"changed_visibility_successfully": "Changed visibility successfully",
|
"changed_visibility_successfully": "Changed visibility successfully",
|
||||||
|
"charging": "Charging",
|
||||||
|
"charging_requirement_mobile_backup": "Background backup requires the device to be charging",
|
||||||
"check_corrupt_asset_backup": "Check for corrupt asset backups",
|
"check_corrupt_asset_backup": "Check for corrupt asset backups",
|
||||||
"check_corrupt_asset_backup_button": "Perform check",
|
"check_corrupt_asset_backup_button": "Perform check",
|
||||||
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
||||||
@@ -1351,6 +1355,7 @@
|
|||||||
"name_or_nickname": "Name or nickname",
|
"name_or_nickname": "Name or nickname",
|
||||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||||
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
||||||
|
"network_requirements": "Network Requirements",
|
||||||
"network_requirements_updated": "Network requirements changed, resetting backup queue",
|
"network_requirements_updated": "Network requirements changed, resetting backup queue",
|
||||||
"networking_settings": "Networking",
|
"networking_settings": "Networking",
|
||||||
"networking_subtitle": "Manage the server endpoint settings",
|
"networking_subtitle": "Manage the server endpoint settings",
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
[tools.dart]
|
|
||||||
version = "3.8.2"
|
|
||||||
backend = "asdf:dart"
|
|
||||||
|
|
||||||
[tools.flutter]
|
|
||||||
version = "3.35.3-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"
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
node = "22.19.0"
|
node = "22.19.0"
|
||||||
flutter = "3.35.3"
|
flutter = "3.35.4"
|
||||||
pnpm = "10.14.0"
|
pnpm = "10.14.0"
|
||||||
dart = "3.8.2"
|
dart = "3.8.2"
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
|||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
experimental = true
|
experimental = true
|
||||||
lockfile = true
|
|
||||||
pin = true
|
pin = true
|
||||||
|
|
||||||
# .github
|
# .github
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"flutter": "3.35.3"
|
"flutter": "3.35.4"
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"dart.flutterSdkPath": ".fvm/versions/3.35.3",
|
"dart.flutterSdkPath": ".fvm/versions/3.35.4",
|
||||||
"dart.lineLength": 120,
|
"dart.lineLength": 120,
|
||||||
"[dart]": {
|
"[dart]": {
|
||||||
"editor.rulers": [120]
|
"editor.rulers": [120]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app.alextran.immich
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||||
|
|
||||||
class ImmichApp : Application() {
|
class ImmichApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -14,6 +15,8 @@ class ImmichApp : Application() {
|
|||||||
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
||||||
// (because of low memory etc.), the backup is never performed.
|
// (because of low memory etc.), the backup is never performed.
|
||||||
// As a workaround, we also run a backup check when initializing the application
|
// As a workaround, we also run a backup check when initializing the application
|
||||||
|
|
||||||
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||||
|
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app.alextran.immich
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ext.SdkExtensions
|
import android.os.ext.SdkExtensions
|
||||||
|
import app.alextran.immich.background.BackgroundEngineLock
|
||||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||||
import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
||||||
import app.alextran.immich.connectivity.ConnectivityApi
|
import app.alextran.immich.connectivity.ConnectivityApi
|
||||||
@@ -25,6 +26,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||||
|
flutterEngine.plugins.add(BackgroundEngineLock())
|
||||||
|
|
||||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
val nativeSyncApiImpl =
|
val nativeSyncApiImpl =
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package app.alextran.immich.background
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
private const val TAG = "BackgroundEngineLock"
|
||||||
|
|
||||||
|
class BackgroundEngineLock : FlutterPlugin {
|
||||||
|
companion object {
|
||||||
|
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
||||||
|
var engineCount = AtomicInteger(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
// work manager task is running while the main app is opened, cancel the worker
|
||||||
|
if (engineCount.incrementAndGet() > 1 && FlutterEngineCache.getInstance()
|
||||||
|
.get(ENGINE_CACHE_KEY) != null
|
||||||
|
) {
|
||||||
|
WorkManager.getInstance(binding.applicationContext)
|
||||||
|
.cancelUniqueWork(BackgroundWorkerApiImpl.BACKGROUND_WORKER_NAME)
|
||||||
|
FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
engineCount.decrementAndGet()
|
||||||
|
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-2
@@ -37,6 +37,36 @@ private object BackgroundWorkerPigeonUtils {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||||
|
if (a is ByteArray && b is ByteArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is IntArray && b is IntArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is LongArray && b is LongArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is DoubleArray && b is DoubleArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is Array<*> && b is Array<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is List<*> && b is List<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is Map<*, *> && b is Map<*, *>) {
|
||||||
|
return a.size == b.size && a.all {
|
||||||
|
(b as Map<Any?, Any?>).containsKey(it.key) &&
|
||||||
|
deepEquals(it.value, b[it.key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,18 +80,63 @@ class FlutterError (
|
|||||||
override val message: String? = null,
|
override val message: String? = null,
|
||||||
val details: Any? = null
|
val details: Any? = null
|
||||||
) : Throwable()
|
) : Throwable()
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class BackgroundWorkerSettings (
|
||||||
|
val requiresCharging: Boolean,
|
||||||
|
val minimumDelaySeconds: Long
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): BackgroundWorkerSettings {
|
||||||
|
val requiresCharging = pigeonVar_list[0] as Boolean
|
||||||
|
val minimumDelaySeconds = pigeonVar_list[1] as Long
|
||||||
|
return BackgroundWorkerSettings(requiresCharging, minimumDelaySeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
requiresCharging,
|
||||||
|
minimumDelaySeconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is BackgroundWorkerSettings) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return BackgroundWorkerPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return super.readValueOfType(type, buffer)
|
return when (type) {
|
||||||
|
129.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
BackgroundWorkerSettings.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> super.readValueOfType(type, buffer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
super.writeValue(stream, value)
|
when (value) {
|
||||||
|
is BackgroundWorkerSettings -> {
|
||||||
|
stream.write(129)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
else -> super.writeValue(stream, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface BackgroundWorkerFgHostApi {
|
interface BackgroundWorkerFgHostApi {
|
||||||
fun enable()
|
fun enable()
|
||||||
|
fun configure(settings: BackgroundWorkerSettings)
|
||||||
fun disable()
|
fun disable()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -89,6 +164,24 @@ interface BackgroundWorkerFgHostApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val settingsArg = args[0] as BackgroundWorkerSettings
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.configure(settingsArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
run {
|
run {
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture
|
|||||||
import com.google.common.util.concurrent.SettableFuture
|
import com.google.common.util.concurrent.SettableFuture
|
||||||
import io.flutter.FlutterInjector
|
import io.flutter.FlutterInjector
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
import io.flutter.embedding.engine.dart.DartExecutor
|
import io.flutter.embedding.engine.dart.DartExecutor
|
||||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -75,6 +76,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
|||||||
|
|
||||||
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||||
engine = FlutterEngine(ctx)
|
engine = FlutterEngine(ctx)
|
||||||
|
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
|
||||||
|
FlutterEngineCache.getInstance()
|
||||||
|
.put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!)
|
||||||
|
|
||||||
// Register custom plugins
|
// Register custom plugins
|
||||||
MainActivity.registerPlugins(ctx, engine!!)
|
MainActivity.registerPlugins(ctx, engine!!)
|
||||||
@@ -188,6 +192,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
|||||||
isComplete = true
|
isComplete = true
|
||||||
engine?.destroy()
|
engine?.destroy()
|
||||||
engine = null
|
engine = null
|
||||||
|
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
|
||||||
flutterApi = null
|
flutterApi = null
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
waitForForegroundPromotion()
|
waitForForegroundPromotion()
|
||||||
|
|||||||
+57
-14
@@ -1,6 +1,7 @@
|
|||||||
package app.alextran.immich.background
|
package app.alextran.immich.background
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.work.BackoffPolicy
|
import androidx.work.BackoffPolicy
|
||||||
@@ -10,7 +11,7 @@ import androidx.work.OneTimeWorkRequest
|
|||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
private const val TAG = "BackgroundUploadImpl"
|
private const val TAG = "BackgroundWorkerApiImpl"
|
||||||
|
|
||||||
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
@@ -19,25 +20,34 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
|||||||
enqueueMediaObserver(ctx)
|
enqueueMediaObserver(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun configure(settings: BackgroundWorkerSettings) {
|
||||||
|
BackgroundWorkerPreferences(ctx).updateSettings(settings)
|
||||||
|
enqueueMediaObserver(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
override fun disable() {
|
override fun disable() {
|
||||||
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
|
WorkManager.getInstance(ctx).apply {
|
||||||
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
cancelUniqueWork(OBSERVER_WORKER_NAME)
|
||||||
|
cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||||
|
}
|
||||||
Log.i(TAG, "Cancelled background upload tasks")
|
Log.i(TAG, "Cancelled background upload tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||||
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||||
|
|
||||||
fun enqueueMediaObserver(ctx: Context) {
|
fun enqueueMediaObserver(ctx: Context) {
|
||||||
val constraints = Constraints.Builder()
|
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||||
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
val constraints = Constraints.Builder().apply {
|
||||||
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
||||||
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
|
addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
||||||
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
|
||||||
.setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
|
addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
||||||
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
|
setTriggerContentUpdateDelay(settings.minimumDelaySeconds, TimeUnit.SECONDS)
|
||||||
.build()
|
setTriggerContentMaxDelay(settings.minimumDelaySeconds * 10, TimeUnit.MINUTES)
|
||||||
|
setRequiresCharging(settings.requiresCharging)
|
||||||
|
}.build()
|
||||||
|
|
||||||
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
|
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
@@ -45,7 +55,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
|||||||
WorkManager.getInstance(ctx)
|
WorkManager.getInstance(ctx)
|
||||||
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||||
|
|
||||||
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"Enqueued media observer worker with name: $OBSERVER_WORKER_NAME and settings: $settings"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enqueueBackgroundWorker(ctx: Context) {
|
fun enqueueBackgroundWorker(ctx: Context) {
|
||||||
@@ -56,9 +69,39 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
|||||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(ctx)
|
WorkManager.getInstance(ctx)
|
||||||
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work)
|
||||||
|
|
||||||
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
|
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class BackgroundWorkerPreferences(private val ctx: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
|
||||||
|
private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
|
||||||
|
private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
|
||||||
|
|
||||||
|
private const val DEFAULT_MIN_DELAY_SECONDS = 30L
|
||||||
|
private const val DEFAULT_REQUIRE_CHARGING = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sp: SharedPreferences by lazy {
|
||||||
|
ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSettings(settings: BackgroundWorkerSettings) {
|
||||||
|
sp.edit().apply {
|
||||||
|
putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
|
||||||
|
putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSettings(): BackgroundWorkerSettings {
|
||||||
|
return BackgroundWorkerSettings(
|
||||||
|
minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS),
|
||||||
|
requiresCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, DEFAULT_REQUIRE_CHARGING),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.provider.MediaStore.Images
|
import android.provider.MediaStore.Images
|
||||||
import android.provider.MediaStore.Video
|
import android.provider.MediaStore.Video
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
@@ -19,7 +18,6 @@ import com.bumptech.glide.Glide
|
|||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.HashMap
|
|
||||||
import java.util.concurrent.CancellationException
|
import java.util.concurrent.CancellationException
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.Future
|
import java.util.concurrent.Future
|
||||||
@@ -202,8 +200,10 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
|||||||
val source = ImageDecoder.createSource(resolver, uri)
|
val source = ImageDecoder.createSource(resolver, uri)
|
||||||
signal.throwIfCanceled()
|
signal.throwIfCanceled()
|
||||||
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||||
val sampleSize = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
|
if (targetWidth > 0 && targetHeight > 0) {
|
||||||
decoder.setTargetSampleSize(sampleSize)
|
val sample = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
|
||||||
|
decoder.setTargetSampleSize(sample)
|
||||||
|
}
|
||||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,40 @@ data class SyncDelta (
|
|||||||
|
|
||||||
override fun hashCode(): Int = toList().hashCode()
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class HashResult (
|
||||||
|
val assetId: String,
|
||||||
|
val error: String? = null,
|
||||||
|
val hash: String? = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): HashResult {
|
||||||
|
val assetId = pigeonVar_list[0] as String
|
||||||
|
val error = pigeonVar_list[1] as String?
|
||||||
|
val hash = pigeonVar_list[2] as String?
|
||||||
|
return HashResult(assetId, error, hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
assetId,
|
||||||
|
error,
|
||||||
|
hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is HashResult) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
@@ -227,6 +261,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
SyncDelta.fromList(it)
|
SyncDelta.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
132.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
HashResult.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> super.readValueOfType(type, buffer)
|
else -> super.readValueOfType(type, buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,11 +283,16 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
stream.write(131)
|
stream.write(131)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
|
is HashResult -> {
|
||||||
|
stream.write(132)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
else -> super.writeValue(stream, value)
|
else -> super.writeValue(stream, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface NativeSyncApi {
|
interface NativeSyncApi {
|
||||||
fun shouldFullSync(): Boolean
|
fun shouldFullSync(): Boolean
|
||||||
@@ -259,7 +303,8 @@ interface NativeSyncApi {
|
|||||||
fun getAlbums(): List<PlatformAlbum>
|
fun getAlbums(): List<PlatformAlbum>
|
||||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
||||||
fun hashPaths(paths: List<String>): List<ByteArray?>
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
|
fun cancelHashing()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NativeSyncApi. */
|
/** The codec used by NativeSyncApi. */
|
||||||
@@ -402,13 +447,33 @@ interface NativeSyncApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
run {
|
run {
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val pathsArg = args[0] as List<String>
|
val assetIdsArg = args[0] as List<String>
|
||||||
|
val allowNetworkAccessArg = args[1] as Boolean
|
||||||
|
api.hashAssets(assetIdsArg, allowNetworkAccessArg) { result: Result<List<HashResult>> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
val wrapped: List<Any?> = try {
|
||||||
listOf(api.hashPaths(pathsArg))
|
api.cancelHashing()
|
||||||
|
listOf(null)
|
||||||
} catch (exception: Throwable) {
|
} catch (exception: Throwable) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
MessagesPigeonUtils.wrapError(exception)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
package app.alextran.immich.sync
|
package app.alextran.immich.sync
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Base64
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
sealed class AssetResult {
|
sealed class AssetResult {
|
||||||
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
||||||
@@ -19,8 +30,12 @@ sealed class AssetResult {
|
|||||||
open class NativeSyncApiImplBase(context: Context) {
|
open class NativeSyncApiImplBase(context: Context) {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
|
private var hashTask: Job? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NativeSyncApiImplBase"
|
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||||
|
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
|
||||||
|
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
|
||||||
|
|
||||||
const val MEDIA_SELECTION =
|
const val MEDIA_SELECTION =
|
||||||
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
||||||
@@ -215,23 +230,74 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hashPaths(paths: List<String>): List<ByteArray?> {
|
fun hashAssets(
|
||||||
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
assetIds: List<String>,
|
||||||
val digest = MessageDigest.getInstance("SHA-1")
|
// allowNetworkAccess is only used on the iOS implementation
|
||||||
|
@Suppress("UNUSED_PARAMETER") allowNetworkAccess: Boolean,
|
||||||
|
callback: (Result<List<HashResult>>) -> Unit
|
||||||
|
) {
|
||||||
|
if (assetIds.isEmpty()) {
|
||||||
|
callback(Result.success(emptyList()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return paths.map { path ->
|
hashTask?.cancel()
|
||||||
|
hashTask = CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
FileInputStream(path).use { file ->
|
val results = assetIds.map { assetId ->
|
||||||
var bytesRead: Int
|
async {
|
||||||
while (file.read(buffer).also { bytesRead = it } > 0) {
|
hashSemaphore.withPermit {
|
||||||
digest.update(buffer, 0, bytesRead)
|
ensureActive()
|
||||||
|
hashAsset(assetId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}.awaitAll()
|
||||||
digest.digest()
|
|
||||||
|
callback(Result.success(results))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
callback(
|
||||||
|
Result.failure(
|
||||||
|
FlutterError(
|
||||||
|
HASHING_CANCELLED_CODE,
|
||||||
|
"Hashing operation was cancelled",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to hash file $path: $e")
|
callback(Result.failure(e))
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun hashAsset(assetId: String): HashResult {
|
||||||
|
return try {
|
||||||
|
val assetUri = ContentUris.withAppendedId(
|
||||||
|
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||||
|
assetId.toLong()
|
||||||
|
)
|
||||||
|
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1")
|
||||||
|
ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
|
||||||
|
var bytesRead: Int
|
||||||
|
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||||
|
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
digest.update(buffer, 0, bytesRead)
|
||||||
|
}
|
||||||
|
} ?: return HashResult(assetId, "Cannot open input stream for asset", null)
|
||||||
|
|
||||||
|
val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
|
||||||
|
HashResult(assetId, null, hashString)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
HashResult(assetId, "Permission denied accessing asset: ${e.message}", null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
HashResult(assetId, "Failed to hash asset: ${e.message}", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelHashing() {
|
||||||
|
hashTask?.cancel()
|
||||||
|
hashTask = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
File diff suppressed because one or more lines are too long
@@ -4,7 +4,6 @@
|
|||||||
*.moved-aside
|
*.moved-aside
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
*.perspectivev3
|
*.perspectivev3
|
||||||
**/*sync/
|
|
||||||
.sconsign.dblite
|
.sconsign.dblite
|
||||||
.tags*
|
.tags*
|
||||||
**/.vagrant/
|
**/.vagrant/
|
||||||
|
|||||||
@@ -50,11 +50,119 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
|
|||||||
return value as! T?
|
return value as! T?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deepEqualsBackgroundWorker(_ lhs: Any?, _ rhs: Any?) -> Bool {
|
||||||
|
let cleanLhs = nilOrValue(lhs) as Any?
|
||||||
|
let cleanRhs = nilOrValue(rhs) as Any?
|
||||||
|
switch (cleanLhs, cleanRhs) {
|
||||||
|
case (nil, nil):
|
||||||
|
return true
|
||||||
|
|
||||||
|
case (nil, _), (_, nil):
|
||||||
|
return false
|
||||||
|
|
||||||
|
case is (Void, Void):
|
||||||
|
return true
|
||||||
|
|
||||||
|
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
|
||||||
|
return cleanLhsHashable == cleanRhsHashable
|
||||||
|
|
||||||
|
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
|
||||||
|
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
|
||||||
|
for (index, element) in cleanLhsArray.enumerated() {
|
||||||
|
if !deepEqualsBackgroundWorker(element, cleanRhsArray[index]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
|
||||||
|
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
|
||||||
|
for (key, cleanLhsValue) in cleanLhsDictionary {
|
||||||
|
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
|
||||||
|
if !deepEqualsBackgroundWorker(cleanLhsValue, cleanRhsDictionary[key]!) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
|
||||||
|
if let valueList = value as? [AnyHashable] {
|
||||||
|
for item in valueList { deepHashBackgroundWorker(value: item, hasher: &hasher) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let valueDict = value as? [AnyHashable: AnyHashable] {
|
||||||
|
for key in valueDict.keys {
|
||||||
|
hasher.combine(key)
|
||||||
|
deepHashBackgroundWorker(value: valueDict[key]!, hasher: &hasher)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let hashableValue = value as? AnyHashable {
|
||||||
|
hasher.combine(hashableValue.hashValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasher.combine(String(describing: value))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
|
struct BackgroundWorkerSettings: Hashable {
|
||||||
|
var requiresCharging: Bool
|
||||||
|
var minimumDelaySeconds: Int64
|
||||||
|
|
||||||
|
|
||||||
|
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||||
|
static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? {
|
||||||
|
let requiresCharging = pigeonVar_list[0] as! Bool
|
||||||
|
let minimumDelaySeconds = pigeonVar_list[1] as! Int64
|
||||||
|
|
||||||
|
return BackgroundWorkerSettings(
|
||||||
|
requiresCharging: requiresCharging,
|
||||||
|
minimumDelaySeconds: minimumDelaySeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
func toList() -> [Any?] {
|
||||||
|
return [
|
||||||
|
requiresCharging,
|
||||||
|
minimumDelaySeconds,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
static func == (lhs: BackgroundWorkerSettings, rhs: BackgroundWorkerSettings) -> Bool {
|
||||||
|
return deepEqualsBackgroundWorker(lhs.toList(), rhs.toList()) }
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
deepHashBackgroundWorker(value: toList(), hasher: &hasher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
|
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
|
||||||
|
override func readValue(ofType type: UInt8) -> Any? {
|
||||||
|
switch type {
|
||||||
|
case 129:
|
||||||
|
return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?])
|
||||||
|
default:
|
||||||
|
return super.readValue(ofType: type)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
|
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
|
||||||
|
override func writeValue(_ value: Any) {
|
||||||
|
if let value = value as? BackgroundWorkerSettings {
|
||||||
|
super.writeByte(129)
|
||||||
|
super.writeValue(value.toList())
|
||||||
|
} else {
|
||||||
|
super.writeValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||||
@@ -74,6 +182,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
|
|||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol BackgroundWorkerFgHostApi {
|
protocol BackgroundWorkerFgHostApi {
|
||||||
func enable() throws
|
func enable() throws
|
||||||
|
func configure(settings: BackgroundWorkerSettings) throws
|
||||||
func disable() throws
|
func disable() throws
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +205,21 @@ class BackgroundWorkerFgHostApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
enableChannel.setMessageHandler(nil)
|
enableChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let configureChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
configureChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let settingsArg = args[0] as! BackgroundWorkerSettings
|
||||||
|
do {
|
||||||
|
try api.configure(settings: settingsArg)
|
||||||
|
reply(wrapResult(nil))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
configureChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(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 {
|
if let api = api {
|
||||||
disableChannel.setMessageHandler { _, reply in
|
disableChannel.setMessageHandler { _, reply in
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
|||||||
func enable() throws {
|
func enable() throws {
|
||||||
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
||||||
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
||||||
print("BackgroundUploadImpl:enbale Background worker scheduled")
|
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(settings: BackgroundWorkerSettings) throws {
|
||||||
|
// Android only
|
||||||
}
|
}
|
||||||
|
|
||||||
func disable() throws {
|
func disable() throws {
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
||||||
print("BackgroundUploadImpl:disableUploadWorker Disabled background workers")
|
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class ThumbnailApiImpl: ThumbnailApi {
|
|||||||
var image: UIImage?
|
var image: UIImage?
|
||||||
Self.imageManager.requestImage(
|
Self.imageManager.requestImage(
|
||||||
for: asset,
|
for: asset,
|
||||||
targetSize: CGSize(width: Double(width), height: Double(height)),
|
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
|
||||||
contentMode: .aspectFill,
|
contentMode: .aspectFill,
|
||||||
options: Self.requestOptions,
|
options: Self.requestOptions,
|
||||||
resultHandler: { (_image, info) -> Void in
|
resultHandler: { (_image, info) -> Void in
|
||||||
|
|||||||
@@ -267,6 +267,39 @@ struct SyncDelta: Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
|
struct HashResult: Hashable {
|
||||||
|
var assetId: String
|
||||||
|
var error: String? = nil
|
||||||
|
var hash: String? = nil
|
||||||
|
|
||||||
|
|
||||||
|
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||||
|
static func fromList(_ pigeonVar_list: [Any?]) -> HashResult? {
|
||||||
|
let assetId = pigeonVar_list[0] as! String
|
||||||
|
let error: String? = nilOrValue(pigeonVar_list[1])
|
||||||
|
let hash: String? = nilOrValue(pigeonVar_list[2])
|
||||||
|
|
||||||
|
return HashResult(
|
||||||
|
assetId: assetId,
|
||||||
|
error: error,
|
||||||
|
hash: hash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
func toList() -> [Any?] {
|
||||||
|
return [
|
||||||
|
assetId,
|
||||||
|
error,
|
||||||
|
hash,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
static func == (lhs: HashResult, rhs: HashResult) -> Bool {
|
||||||
|
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
deepHashMessages(value: toList(), hasher: &hasher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||||
override func readValue(ofType type: UInt8) -> Any? {
|
override func readValue(ofType type: UInt8) -> Any? {
|
||||||
switch type {
|
switch type {
|
||||||
@@ -276,6 +309,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
|||||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||||
case 131:
|
case 131:
|
||||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||||
|
case 132:
|
||||||
|
return HashResult.fromList(self.readValue() as! [Any?])
|
||||||
default:
|
default:
|
||||||
return super.readValue(ofType: type)
|
return super.readValue(ofType: type)
|
||||||
}
|
}
|
||||||
@@ -293,6 +328,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
|||||||
} else if let value = value as? SyncDelta {
|
} else if let value = value as? SyncDelta {
|
||||||
super.writeByte(131)
|
super.writeByte(131)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
|
} else if let value = value as? HashResult {
|
||||||
|
super.writeByte(132)
|
||||||
|
super.writeValue(value.toList())
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(value)
|
super.writeValue(value)
|
||||||
}
|
}
|
||||||
@@ -313,6 +351,7 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
|||||||
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
|
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol NativeSyncApi {
|
protocol NativeSyncApi {
|
||||||
func shouldFullSync() throws -> Bool
|
func shouldFullSync() throws -> Bool
|
||||||
@@ -323,7 +362,8 @@ protocol NativeSyncApi {
|
|||||||
func getAlbums() throws -> [PlatformAlbum]
|
func getAlbums() throws -> [PlatformAlbum]
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
||||||
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?]
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
|
func cancelHashing() throws
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -459,22 +499,38 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
getAssetsForAlbumChannel.setMessageHandler(nil)
|
getAssetsForAlbumChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
let hashPathsChannel = taskQueue == nil
|
let hashAssetsChannel = taskQueue == nil
|
||||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
hashPathsChannel.setMessageHandler { message, reply in
|
hashAssetsChannel.setMessageHandler { message, reply in
|
||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let pathsArg = args[0] as! [String]
|
let assetIdsArg = args[0] as! [String]
|
||||||
|
let allowNetworkAccessArg = args[1] as! Bool
|
||||||
|
api.hashAssets(assetIds: assetIdsArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hashAssetsChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
cancelHashingChannel.setMessageHandler { _, reply in
|
||||||
do {
|
do {
|
||||||
let result = try api.hashPaths(paths: pathsArg)
|
try api.cancelHashing()
|
||||||
reply(wrapResult(result))
|
reply(wrapResult(nil))
|
||||||
} catch {
|
} catch {
|
||||||
reply(wrapError(error))
|
reply(wrapError(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
hashPathsChannel.setMessageHandler(nil)
|
cancelHashingChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,30 +17,16 @@ struct AssetWrapper: Hashable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PHAsset {
|
|
||||||
func toPlatformAsset() -> PlatformAsset {
|
|
||||||
return PlatformAsset(
|
|
||||||
id: localIdentifier,
|
|
||||||
name: title(),
|
|
||||||
type: Int64(mediaType.rawValue),
|
|
||||||
createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
|
|
||||||
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
|
|
||||||
width: Int64(pixelWidth),
|
|
||||||
height: Int64(pixelHeight),
|
|
||||||
durationInSeconds: Int64(duration),
|
|
||||||
orientation: 0,
|
|
||||||
isFavorite: isFavorite
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NativeSyncApiImpl: NativeSyncApi {
|
class NativeSyncApiImpl: NativeSyncApi {
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
private let recoveredAlbumSubType = 1000000219
|
private let recoveredAlbumSubType = 1000000219
|
||||||
|
|
||||||
private let hashBufferSize = 2 * 1024 * 1024
|
private var hashTask: Task<Void, Error>?
|
||||||
|
private static let hashCancelledCode = "HASH_CANCELLED"
|
||||||
|
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
||||||
|
|
||||||
|
|
||||||
init(with defaults: UserDefaults = .standard) {
|
init(with defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
@@ -96,7 +82,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
||||||
for i in 0..<collections.count {
|
for i in 0..<collections.count {
|
||||||
let album = collections.object(at: i)
|
let album = collections.object(at: i)
|
||||||
|
|
||||||
// Ignore recovered album
|
// Ignore recovered album
|
||||||
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
||||||
continue;
|
continue;
|
||||||
@@ -254,7 +240,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
||||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = PHAsset.fetchAssets(in: album, options: options)
|
let result = PHAsset.fetchAssets(in: album, options: options)
|
||||||
if(result.count == 0) {
|
if(result.count == 0) {
|
||||||
return []
|
return []
|
||||||
@@ -267,23 +253,114 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return assets
|
return assets
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] {
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
||||||
return paths.map { path in
|
if let prevTask = hashTask {
|
||||||
guard let file = FileHandle(forReadingAtPath: path) else {
|
prevTask.cancel()
|
||||||
print("Cannot open file: \(path)")
|
hashTask = nil
|
||||||
return nil
|
}
|
||||||
}
|
hashTask = Task { [weak self] in
|
||||||
|
var missingAssetIds = Set(assetIds)
|
||||||
var hasher = Insecure.SHA1()
|
var assets = [PHAsset]()
|
||||||
while autoreleasepool(invoking: {
|
assets.reserveCapacity(assetIds.count)
|
||||||
let chunk = file.readData(ofLength: hashBufferSize)
|
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
|
||||||
guard !chunk.isEmpty else { return false }
|
if Task.isCancelled {
|
||||||
hasher.update(data: chunk)
|
stop.pointee = true
|
||||||
return true
|
return
|
||||||
}) { }
|
}
|
||||||
|
missingAssetIds.remove(asset.localIdentifier)
|
||||||
let digest = hasher.finalize()
|
assets.append(asset)
|
||||||
return FlutterStandardTypedData(bytes: Data(digest))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
return completion(Self.hashCancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
||||||
|
var results = [HashResult]()
|
||||||
|
results.reserveCapacity(assets.count)
|
||||||
|
for asset in assets {
|
||||||
|
if Task.isCancelled {
|
||||||
|
return completion(Self.hashCancelled)
|
||||||
|
}
|
||||||
|
taskGroup.addTask {
|
||||||
|
guard let self = self else { return nil }
|
||||||
|
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await result in taskGroup {
|
||||||
|
guard let result = result else {
|
||||||
|
return completion(Self.hashCancelled)
|
||||||
|
}
|
||||||
|
results.append(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
for missing in missingAssetIds {
|
||||||
|
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(.success(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelHashing() {
|
||||||
|
hashTask?.cancel()
|
||||||
|
hashTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
|
||||||
|
class RequestRef {
|
||||||
|
var id: PHAssetResourceDataRequestID?
|
||||||
|
}
|
||||||
|
let requestRef = RequestRef()
|
||||||
|
return await withTaskCancellationHandler(operation: {
|
||||||
|
if Task.isCancelled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let resource = asset.getResource() else {
|
||||||
|
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = PHAssetResourceRequestOptions()
|
||||||
|
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
var hasher = Insecure.SHA1()
|
||||||
|
|
||||||
|
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||||
|
for: resource,
|
||||||
|
options: options,
|
||||||
|
dataReceivedHandler: { data in
|
||||||
|
hasher.update(data: data)
|
||||||
|
},
|
||||||
|
completionHandler: { error in
|
||||||
|
let result: HashResult? = switch (error) {
|
||||||
|
case let e as PHPhotosError where e.code == .userCancelled: nil
|
||||||
|
case let .some(e): HashResult(
|
||||||
|
assetId: asset.localIdentifier,
|
||||||
|
error: "Failed to hash asset: \(e.localizedDescription)",
|
||||||
|
hash: nil
|
||||||
|
)
|
||||||
|
case .none:
|
||||||
|
HashResult(
|
||||||
|
assetId: asset.localIdentifier,
|
||||||
|
error: nil,
|
||||||
|
hash: Data(hasher.finalize()).base64EncodedString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, onCancel: {
|
||||||
|
guard let requestId = requestRef.id else { return }
|
||||||
|
PHAssetResourceManager.default().cancelDataRequest(requestId)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import Photos
|
||||||
|
|
||||||
|
extension PHAsset {
|
||||||
|
func toPlatformAsset() -> PlatformAsset {
|
||||||
|
return PlatformAsset(
|
||||||
|
id: localIdentifier,
|
||||||
|
name: title,
|
||||||
|
type: Int64(mediaType.rawValue),
|
||||||
|
createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
|
||||||
|
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
|
||||||
|
width: Int64(pixelWidth),
|
||||||
|
height: Int64(pixelHeight),
|
||||||
|
durationInSeconds: Int64(duration),
|
||||||
|
orientation: 0,
|
||||||
|
isFavorite: isFavorite
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
return filename ?? originalFilename ?? "<unknown>"
|
||||||
|
}
|
||||||
|
|
||||||
|
var filename: String? {
|
||||||
|
return value(forKey: "filename") as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
|
||||||
|
var originalFilename: String? {
|
||||||
|
return getResource()?.originalFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
func getResource() -> PHAssetResource? {
|
||||||
|
let resources = PHAssetResource.assetResources(for: self)
|
||||||
|
|
||||||
|
let filteredResources = resources.filter { $0.isMediaResource && isValidResourceType($0.type) }
|
||||||
|
|
||||||
|
guard !filteredResources.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if filteredResources.count == 1 {
|
||||||
|
return filteredResources.first
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentResource = filteredResources.first(where: { $0.isCurrent }) {
|
||||||
|
return currentResource
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fullSizeResource = filteredResources.first(where: { isFullSizeResourceType($0.type) }) {
|
||||||
|
return fullSizeResource
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
|
||||||
|
switch mediaType {
|
||||||
|
case .image:
|
||||||
|
return [.photo, .alternatePhoto, .fullSizePhoto].contains(type)
|
||||||
|
case .video:
|
||||||
|
return [.video, .fullSizeVideo, .fullSizePairedVideo].contains(type)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFullSizeResourceType(_ type: PHAssetResourceType) -> Bool {
|
||||||
|
switch mediaType {
|
||||||
|
case .image:
|
||||||
|
return type == .fullSizePhoto
|
||||||
|
case .video:
|
||||||
|
return type == .fullSizeVideo
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
extension PHAssetResource {
|
||||||
|
var isCurrent: Bool {
|
||||||
|
return value(forKey: "isCurrent") as? Bool ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMediaResource: Bool {
|
||||||
|
var isMedia = type != .adjustmentData
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
isMedia = isMedia && type != .photoProxy
|
||||||
|
}
|
||||||
|
return isMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
const int noDbId = -9223372036854775808; // from Isar
|
const int noDbId = -9223372036854775808; // from Isar
|
||||||
const double downloadCompleted = -1;
|
const double downloadCompleted = -1;
|
||||||
const double downloadFailed = -2;
|
const double downloadFailed = -2;
|
||||||
@@ -10,7 +12,7 @@ const int kSyncEventBatchSize = 5000;
|
|||||||
const int kFetchLocalAssetsBatchSize = 40000;
|
const int kFetchLocalAssetsBatchSize = 40000;
|
||||||
|
|
||||||
// Hash batch limits
|
// Hash batch limits
|
||||||
const int kBatchHashFileLimit = 256;
|
final int kBatchHashFileLimit = Platform.isIOS ? 32 : 512;
|
||||||
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
||||||
|
|
||||||
// Secure storage keys
|
// Secure storage keys
|
||||||
|
|||||||
@@ -40,13 +40,12 @@ class AssetService {
|
|||||||
|
|
||||||
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
|
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
|
||||||
if (asset.stackId == null) {
|
if (asset.stackId == null) {
|
||||||
return [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return _remoteAssetRepository.getStackChildren(asset).then((assets) {
|
final stack = await _remoteAssetRepository.getStackChildren(asset);
|
||||||
// Include the primary asset in the stack as the first item
|
// Include the primary asset in the stack as the first item
|
||||||
return [asset, ...assets];
|
return [asset, ...stack];
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ class BackgroundWorkerFgService {
|
|||||||
// TODO: Move this call to native side once old timeline is removed
|
// TODO: Move this call to native side once old timeline is removed
|
||||||
Future<void> enable() => _foregroundHostApi.enable();
|
Future<void> enable() => _foregroundHostApi.enable();
|
||||||
|
|
||||||
|
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
|
||||||
|
BackgroundWorkerSettings(
|
||||||
|
minimumDelaySeconds:
|
||||||
|
minimumDelaySeconds ??
|
||||||
|
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
|
||||||
|
requiresCharging:
|
||||||
|
requireCharging ??
|
||||||
|
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> disable() => _foregroundHostApi.disable();
|
Future<void> disable() => _foregroundHostApi.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +184,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final backgroundSyncManager = _ref.read(backgroundSyncProvider);
|
final backgroundSyncManager = _ref.read(backgroundSyncProvider);
|
||||||
|
final nativeSyncApi = _ref.read(nativeSyncApiProvider);
|
||||||
_isCleanedUp = true;
|
_isCleanedUp = true;
|
||||||
_ref.dispose();
|
_ref.dispose();
|
||||||
|
|
||||||
@@ -188,7 +200,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
_drift.close(),
|
_drift.close(),
|
||||||
_driftLogger.close(),
|
_driftLogger.close(),
|
||||||
backgroundSyncManager.cancel(),
|
backgroundSyncManager.cancel(),
|
||||||
backgroundSyncManager.cancelLocal(),
|
nativeSyncApi.cancelHashing(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (_isar.isOpen) {
|
if (_isar.isOpen) {
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import 'dart:convert';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.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_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||||
|
|
||||||
class HashService {
|
class HashService {
|
||||||
final int batchSizeLimit;
|
final int _batchSize;
|
||||||
final int batchFileLimit;
|
|
||||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final StorageRepository _storageRepository;
|
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
final bool Function()? _cancelChecker;
|
final bool Function()? _cancelChecker;
|
||||||
final _log = Logger('HashService');
|
final _log = Logger('HashService');
|
||||||
@@ -22,37 +20,42 @@ class HashService {
|
|||||||
HashService({
|
HashService({
|
||||||
required DriftLocalAlbumRepository localAlbumRepository,
|
required DriftLocalAlbumRepository localAlbumRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required StorageRepository storageRepository,
|
|
||||||
required NativeSyncApi nativeSyncApi,
|
required NativeSyncApi nativeSyncApi,
|
||||||
bool Function()? cancelChecker,
|
bool Function()? cancelChecker,
|
||||||
this.batchSizeLimit = kBatchHashSizeLimit,
|
int? batchSize,
|
||||||
this.batchFileLimit = kBatchHashFileLimit,
|
|
||||||
}) : _localAlbumRepository = localAlbumRepository,
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_storageRepository = storageRepository,
|
|
||||||
_cancelChecker = cancelChecker,
|
_cancelChecker = cancelChecker,
|
||||||
_nativeSyncApi = nativeSyncApi;
|
_nativeSyncApi = nativeSyncApi,
|
||||||
|
_batchSize = batchSize ?? kBatchHashFileLimit;
|
||||||
|
|
||||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||||
|
|
||||||
Future<void> hashAssets() async {
|
Future<void> hashAssets() async {
|
||||||
_log.info("Starting hashing of assets");
|
_log.info("Starting hashing of assets");
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
// Sorted by backupSelection followed by isCloud
|
try {
|
||||||
final localAlbums = await _localAlbumRepository.getAll(
|
// Sorted by backupSelection followed by isCloud
|
||||||
sortBy: {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum},
|
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||||
);
|
|
||||||
|
|
||||||
for (final album in localAlbums) {
|
for (final album in localAlbums) {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
_log.warning("Hashing cancelled. Stopped processing albums.");
|
_log.warning("Hashing cancelled. Stopped processing albums.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
||||||
if (assetsToHash.isNotEmpty) {
|
if (assetsToHash.isNotEmpty) {
|
||||||
await _hashAssets(album, assetsToHash);
|
await _hashAssets(album, assetsToHash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (e.code == _kHashCancelledCode) {
|
||||||
|
_log.warning("Hashing cancelled by platform");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e, s) {
|
||||||
|
_log.severe("Error during hashing", e, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
@@ -63,8 +66,7 @@ class HashService {
|
|||||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
/// 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.
|
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
||||||
int bytesProcessed = 0;
|
final toHash = <String, LocalAsset>{};
|
||||||
final toHash = <_AssetToPath>[];
|
|
||||||
|
|
||||||
for (final asset in assetsToHash) {
|
for (final asset in assetsToHash) {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
@@ -72,21 +74,10 @@ class HashService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
toHash[asset.id] = asset;
|
||||||
if (file == null) {
|
if (toHash.length == _batchSize) {
|
||||||
_log.warning(
|
|
||||||
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
bytesProcessed += await file.length();
|
|
||||||
toHash.add(_AssetToPath(asset: asset, path: file.path));
|
|
||||||
|
|
||||||
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
|
|
||||||
await _processBatch(album, toHash);
|
await _processBatch(album, toHash);
|
||||||
toHash.clear();
|
toHash.clear();
|
||||||
bytesProcessed = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,33 +85,36 @@ class HashService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Processes a batch of assets.
|
/// Processes a batch of assets.
|
||||||
Future<void> _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async {
|
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
|
||||||
if (toHash.isEmpty) {
|
if (toHash.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.fine("Hashing ${toHash.length} files");
|
_log.fine("Hashing ${toHash.length} files");
|
||||||
|
|
||||||
final hashed = <LocalAsset>[];
|
final hashed = <String, String>{};
|
||||||
final hashes = await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList());
|
final hashResults = await _nativeSyncApi.hashAssets(
|
||||||
|
toHash.keys.toList(),
|
||||||
|
allowNetworkAccess: album.backupSelection == BackupSelection.selected,
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
hashes.length == toHash.length,
|
hashResults.length == toHash.length,
|
||||||
"Hashes length does not match toHash length: ${hashes.length} != ${toHash.length}",
|
"Hashes length does not match toHash length: ${hashResults.length} != ${toHash.length}",
|
||||||
);
|
);
|
||||||
|
|
||||||
for (int i = 0; i < hashes.length; i++) {
|
for (int i = 0; i < hashResults.length; i++) {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
_log.warning("Hashing cancelled. Stopped processing batch.");
|
_log.warning("Hashing cancelled. Stopped processing batch.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final hash = hashes[i];
|
final hashResult = hashResults[i];
|
||||||
final asset = toHash[i].asset;
|
if (hashResult.hash != null) {
|
||||||
if (hash?.length == 20) {
|
hashed[hashResult.assetId] = hashResult.hash!;
|
||||||
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
|
|
||||||
} else {
|
} else {
|
||||||
|
final asset = toHash[hashResult.assetId];
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
|
"Failed to hash asset with id: ${hashResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}, from album: ${album.name}. Error: ${hashResult.error ?? "unknown"}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,13 +122,5 @@ class HashService {
|
|||||||
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
||||||
|
|
||||||
await _localAssetRepository.updateHashes(hashed);
|
await _localAssetRepository.updateHashes(hashed);
|
||||||
await _storageRepository.clearCache();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AssetToPath {
|
|
||||||
final LocalAsset asset;
|
|
||||||
final String path;
|
|
||||||
|
|
||||||
const _AssetToPath({required this.asset, required this.path});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin {
|
|||||||
|
|
||||||
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
|
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
// Used for mark & sweep
|
||||||
|
BoolColumn get marker_ => boolean().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {assetId, albumId};
|
Set<Column> get primaryKey => {assetId, albumId};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ typedef $$LocalAlbumAssetEntityTableCreateCompanionBuilder =
|
|||||||
i1.LocalAlbumAssetEntityCompanion Function({
|
i1.LocalAlbumAssetEntityCompanion Function({
|
||||||
required String assetId,
|
required String assetId,
|
||||||
required String albumId,
|
required String albumId,
|
||||||
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
|
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
|
||||||
i1.LocalAlbumAssetEntityCompanion Function({
|
i1.LocalAlbumAssetEntityCompanion Function({
|
||||||
i0.Value<String> assetId,
|
i0.Value<String> assetId,
|
||||||
i0.Value<String> albumId,
|
i0.Value<String> albumId,
|
||||||
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
|
|
||||||
final class $$LocalAlbumAssetEntityTableReferences
|
final class $$LocalAlbumAssetEntityTableReferences
|
||||||
@@ -113,6 +115,11 @@ class $$LocalAlbumAssetEntityTableFilterComposer
|
|||||||
super.$addJoinBuilderToRootComposer,
|
super.$addJoinBuilderToRootComposer,
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
});
|
});
|
||||||
|
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
|
||||||
|
column: $table.marker_,
|
||||||
|
builder: (column) => i0.ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
i3.$$LocalAssetEntityTableFilterComposer get assetId {
|
i3.$$LocalAssetEntityTableFilterComposer get assetId {
|
||||||
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
|
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
|
||||||
composer: this,
|
composer: this,
|
||||||
@@ -177,6 +184,11 @@ class $$LocalAlbumAssetEntityTableOrderingComposer
|
|||||||
super.$addJoinBuilderToRootComposer,
|
super.$addJoinBuilderToRootComposer,
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
});
|
});
|
||||||
|
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
|
||||||
|
column: $table.marker_,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
i3.$$LocalAssetEntityTableOrderingComposer get assetId {
|
i3.$$LocalAssetEntityTableOrderingComposer get assetId {
|
||||||
final i3.$$LocalAssetEntityTableOrderingComposer composer =
|
final i3.$$LocalAssetEntityTableOrderingComposer composer =
|
||||||
$composerBuilder(
|
$composerBuilder(
|
||||||
@@ -243,6 +255,9 @@ class $$LocalAlbumAssetEntityTableAnnotationComposer
|
|||||||
super.$addJoinBuilderToRootComposer,
|
super.$addJoinBuilderToRootComposer,
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
});
|
});
|
||||||
|
i0.GeneratedColumn<bool> get marker_ =>
|
||||||
|
$composableBuilder(column: $table.marker_, builder: (column) => column);
|
||||||
|
|
||||||
i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
|
i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
|
||||||
final i3.$$LocalAssetEntityTableAnnotationComposer composer =
|
final i3.$$LocalAssetEntityTableAnnotationComposer composer =
|
||||||
$composerBuilder(
|
$composerBuilder(
|
||||||
@@ -344,16 +359,22 @@ class $$LocalAlbumAssetEntityTableTableManager
|
|||||||
({
|
({
|
||||||
i0.Value<String> assetId = const i0.Value.absent(),
|
i0.Value<String> assetId = const i0.Value.absent(),
|
||||||
i0.Value<String> albumId = const i0.Value.absent(),
|
i0.Value<String> albumId = const i0.Value.absent(),
|
||||||
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
}) => i1.LocalAlbumAssetEntityCompanion(
|
}) => i1.LocalAlbumAssetEntityCompanion(
|
||||||
assetId: assetId,
|
assetId: assetId,
|
||||||
albumId: albumId,
|
albumId: albumId,
|
||||||
|
marker_: marker_,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({required String assetId, required String albumId}) =>
|
({
|
||||||
i1.LocalAlbumAssetEntityCompanion.insert(
|
required String assetId,
|
||||||
assetId: assetId,
|
required String albumId,
|
||||||
albumId: albumId,
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
),
|
}) => i1.LocalAlbumAssetEntityCompanion.insert(
|
||||||
|
assetId: assetId,
|
||||||
|
albumId: albumId,
|
||||||
|
marker_: marker_,
|
||||||
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
@@ -477,8 +498,22 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
|
|||||||
'REFERENCES local_album_entity (id) ON DELETE CASCADE',
|
'REFERENCES local_album_entity (id) ON DELETE CASCADE',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta(
|
||||||
|
'marker_',
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
List<i0.GeneratedColumn> get $columns => [assetId, albumId];
|
late final i0.GeneratedColumn<bool> marker_ = i0.GeneratedColumn<bool>(
|
||||||
|
'marker',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: i0.DriftSqlType.bool,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||||
|
'CHECK ("marker" IN (0, 1))',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<i0.GeneratedColumn> get $columns => [assetId, albumId, marker_];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@override
|
@override
|
||||||
@@ -507,6 +542,12 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
|
|||||||
} else if (isInserting) {
|
} else if (isInserting) {
|
||||||
context.missing(_albumIdMeta);
|
context.missing(_albumIdMeta);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('marker')) {
|
||||||
|
context.handle(
|
||||||
|
_marker_Meta,
|
||||||
|
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,6 +568,10 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
|
|||||||
i0.DriftSqlType.string,
|
i0.DriftSqlType.string,
|
||||||
data['${effectivePrefix}album_id'],
|
data['${effectivePrefix}album_id'],
|
||||||
)!,
|
)!,
|
||||||
|
marker_: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.bool,
|
||||||
|
data['${effectivePrefix}marker'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,15 +590,20 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
|||||||
implements i0.Insertable<i1.LocalAlbumAssetEntityData> {
|
implements i0.Insertable<i1.LocalAlbumAssetEntityData> {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final String albumId;
|
final String albumId;
|
||||||
|
final bool? marker_;
|
||||||
const LocalAlbumAssetEntityData({
|
const LocalAlbumAssetEntityData({
|
||||||
required this.assetId,
|
required this.assetId,
|
||||||
required this.albumId,
|
required this.albumId,
|
||||||
|
this.marker_,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
final map = <String, i0.Expression>{};
|
final map = <String, i0.Expression>{};
|
||||||
map['asset_id'] = i0.Variable<String>(assetId);
|
map['asset_id'] = i0.Variable<String>(assetId);
|
||||||
map['album_id'] = i0.Variable<String>(albumId);
|
map['album_id'] = i0.Variable<String>(albumId);
|
||||||
|
if (!nullToAbsent || marker_ != null) {
|
||||||
|
map['marker'] = i0.Variable<bool>(marker_);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +615,7 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
|||||||
return LocalAlbumAssetEntityData(
|
return LocalAlbumAssetEntityData(
|
||||||
assetId: serializer.fromJson<String>(json['assetId']),
|
assetId: serializer.fromJson<String>(json['assetId']),
|
||||||
albumId: serializer.fromJson<String>(json['albumId']),
|
albumId: serializer.fromJson<String>(json['albumId']),
|
||||||
|
marker_: serializer.fromJson<bool?>(json['marker_']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -573,20 +624,26 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
|||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'assetId': serializer.toJson<String>(assetId),
|
'assetId': serializer.toJson<String>(assetId),
|
||||||
'albumId': serializer.toJson<String>(albumId),
|
'albumId': serializer.toJson<String>(albumId),
|
||||||
|
'marker_': serializer.toJson<bool?>(marker_),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
i1.LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) =>
|
i1.LocalAlbumAssetEntityData copyWith({
|
||||||
i1.LocalAlbumAssetEntityData(
|
String? assetId,
|
||||||
assetId: assetId ?? this.assetId,
|
String? albumId,
|
||||||
albumId: albumId ?? this.albumId,
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
);
|
}) => i1.LocalAlbumAssetEntityData(
|
||||||
|
assetId: assetId ?? this.assetId,
|
||||||
|
albumId: albumId ?? this.albumId,
|
||||||
|
marker_: marker_.present ? marker_.value : this.marker_,
|
||||||
|
);
|
||||||
LocalAlbumAssetEntityData copyWithCompanion(
|
LocalAlbumAssetEntityData copyWithCompanion(
|
||||||
i1.LocalAlbumAssetEntityCompanion data,
|
i1.LocalAlbumAssetEntityCompanion data,
|
||||||
) {
|
) {
|
||||||
return LocalAlbumAssetEntityData(
|
return LocalAlbumAssetEntityData(
|
||||||
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
||||||
albumId: data.albumId.present ? data.albumId.value : this.albumId,
|
albumId: data.albumId.present ? data.albumId.value : this.albumId,
|
||||||
|
marker_: data.marker_.present ? data.marker_.value : this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,51 +651,60 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
|||||||
String toString() {
|
String toString() {
|
||||||
return (StringBuffer('LocalAlbumAssetEntityData(')
|
return (StringBuffer('LocalAlbumAssetEntityData(')
|
||||||
..write('assetId: $assetId, ')
|
..write('assetId: $assetId, ')
|
||||||
..write('albumId: $albumId')
|
..write('albumId: $albumId, ')
|
||||||
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(assetId, albumId);
|
int get hashCode => Object.hash(assetId, albumId, marker_);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
(other is i1.LocalAlbumAssetEntityData &&
|
(other is i1.LocalAlbumAssetEntityData &&
|
||||||
other.assetId == this.assetId &&
|
other.assetId == this.assetId &&
|
||||||
other.albumId == this.albumId);
|
other.albumId == this.albumId &&
|
||||||
|
other.marker_ == this.marker_);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAlbumAssetEntityCompanion
|
class LocalAlbumAssetEntityCompanion
|
||||||
extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> {
|
extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> {
|
||||||
final i0.Value<String> assetId;
|
final i0.Value<String> assetId;
|
||||||
final i0.Value<String> albumId;
|
final i0.Value<String> albumId;
|
||||||
|
final i0.Value<bool?> marker_;
|
||||||
const LocalAlbumAssetEntityCompanion({
|
const LocalAlbumAssetEntityCompanion({
|
||||||
this.assetId = const i0.Value.absent(),
|
this.assetId = const i0.Value.absent(),
|
||||||
this.albumId = const i0.Value.absent(),
|
this.albumId = const i0.Value.absent(),
|
||||||
|
this.marker_ = const i0.Value.absent(),
|
||||||
});
|
});
|
||||||
LocalAlbumAssetEntityCompanion.insert({
|
LocalAlbumAssetEntityCompanion.insert({
|
||||||
required String assetId,
|
required String assetId,
|
||||||
required String albumId,
|
required String albumId,
|
||||||
|
this.marker_ = const i0.Value.absent(),
|
||||||
}) : assetId = i0.Value(assetId),
|
}) : assetId = i0.Value(assetId),
|
||||||
albumId = i0.Value(albumId);
|
albumId = i0.Value(albumId);
|
||||||
static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({
|
static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({
|
||||||
i0.Expression<String>? assetId,
|
i0.Expression<String>? assetId,
|
||||||
i0.Expression<String>? albumId,
|
i0.Expression<String>? albumId,
|
||||||
|
i0.Expression<bool>? marker_,
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
if (assetId != null) 'asset_id': assetId,
|
if (assetId != null) 'asset_id': assetId,
|
||||||
if (albumId != null) 'album_id': albumId,
|
if (albumId != null) 'album_id': albumId,
|
||||||
|
if (marker_ != null) 'marker': marker_,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
i1.LocalAlbumAssetEntityCompanion copyWith({
|
i1.LocalAlbumAssetEntityCompanion copyWith({
|
||||||
i0.Value<String>? assetId,
|
i0.Value<String>? assetId,
|
||||||
i0.Value<String>? albumId,
|
i0.Value<String>? albumId,
|
||||||
|
i0.Value<bool?>? marker_,
|
||||||
}) {
|
}) {
|
||||||
return i1.LocalAlbumAssetEntityCompanion(
|
return i1.LocalAlbumAssetEntityCompanion(
|
||||||
assetId: assetId ?? this.assetId,
|
assetId: assetId ?? this.assetId,
|
||||||
albumId: albumId ?? this.albumId,
|
albumId: albumId ?? this.albumId,
|
||||||
|
marker_: marker_ ?? this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,6 +717,9 @@ class LocalAlbumAssetEntityCompanion
|
|||||||
if (albumId.present) {
|
if (albumId.present) {
|
||||||
map['album_id'] = i0.Variable<String>(albumId.value);
|
map['album_id'] = i0.Variable<String>(albumId.value);
|
||||||
}
|
}
|
||||||
|
if (marker_.present) {
|
||||||
|
map['marker'] = i0.Variable<bool>(marker_.value);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,7 +727,8 @@ class LocalAlbumAssetEntityCompanion
|
|||||||
String toString() {
|
String toString() {
|
||||||
return (StringBuffer('LocalAlbumAssetEntityCompanion(')
|
return (StringBuffer('LocalAlbumAssetEntityCompanion(')
|
||||||
..write('assetId: $assetId, ')
|
..write('assetId: $assetId, ')
|
||||||
..write('albumId: $albumId')
|
..write('albumId: $albumId, ')
|
||||||
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 10;
|
int get schemaVersion => 11;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -156,6 +156,9 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||||||
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
|
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
|
||||||
await m.alterTable(TableMigration(v10.userEntity));
|
await m.alterTable(TableMigration(v10.userEntity));
|
||||||
},
|
},
|
||||||
|
from10To11: (m, v11) async {
|
||||||
|
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4270,6 +4270,395 @@ i1.GeneratedColumn<String> _column_94(String aliasedName) =>
|
|||||||
true,
|
true,
|
||||||
type: i1.DriftSqlType.string,
|
type: i1.DriftSqlType.string,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final class Schema11 extends i0.VersionedSchema {
|
||||||
|
Schema11({required super.database}) : super(version: 11);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
userEntity,
|
||||||
|
remoteAssetEntity,
|
||||||
|
stackEntity,
|
||||||
|
localAssetEntity,
|
||||||
|
remoteAlbumEntity,
|
||||||
|
localAlbumEntity,
|
||||||
|
localAlbumAssetEntity,
|
||||||
|
idxLocalAssetChecksum,
|
||||||
|
idxRemoteAssetOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerLibraryChecksum,
|
||||||
|
idxRemoteAssetChecksum,
|
||||||
|
authUserEntity,
|
||||||
|
userMetadataEntity,
|
||||||
|
partnerEntity,
|
||||||
|
remoteExifEntity,
|
||||||
|
remoteAlbumAssetEntity,
|
||||||
|
remoteAlbumUserEntity,
|
||||||
|
memoryEntity,
|
||||||
|
memoryAssetEntity,
|
||||||
|
personEntity,
|
||||||
|
assetFaceEntity,
|
||||||
|
storeEntity,
|
||||||
|
idxLatLng,
|
||||||
|
];
|
||||||
|
late final Shape20 userEntity = Shape20(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_91,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape17 remoteAssetEntity = Shape17(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_13,
|
||||||
|
_column_14,
|
||||||
|
_column_15,
|
||||||
|
_column_16,
|
||||||
|
_column_17,
|
||||||
|
_column_18,
|
||||||
|
_column_19,
|
||||||
|
_column_20,
|
||||||
|
_column_21,
|
||||||
|
_column_86,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape3 stackEntity = Shape3(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'stack_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape2 localAssetEntity = Shape2(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_22,
|
||||||
|
_column_14,
|
||||||
|
_column_23,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape9 remoteAlbumEntity = Shape9(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_56,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_57,
|
||||||
|
_column_58,
|
||||||
|
_column_59,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape19 localAlbumEntity = Shape19(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_5,
|
||||||
|
_column_31,
|
||||||
|
_column_32,
|
||||||
|
_column_90,
|
||||||
|
_column_33,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_34, _column_35, _column_33],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||||
|
'idx_local_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_owner_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_library_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
late final Shape21 authUserEntity = Shape21(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'auth_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_2,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_92,
|
||||||
|
_column_93,
|
||||||
|
_column_7,
|
||||||
|
_column_94,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape4 userMetadataEntity = Shape4(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_metadata_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||||
|
columns: [_column_25, _column_26, _column_27],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape5 partnerEntity = Shape5(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'partner_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||||
|
columns: [_column_28, _column_29, _column_30],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape8 remoteExifEntity = Shape8(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_exif_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_36,
|
||||||
|
_column_37,
|
||||||
|
_column_38,
|
||||||
|
_column_39,
|
||||||
|
_column_40,
|
||||||
|
_column_41,
|
||||||
|
_column_11,
|
||||||
|
_column_10,
|
||||||
|
_column_42,
|
||||||
|
_column_43,
|
||||||
|
_column_44,
|
||||||
|
_column_45,
|
||||||
|
_column_46,
|
||||||
|
_column_47,
|
||||||
|
_column_48,
|
||||||
|
_column_49,
|
||||||
|
_column_50,
|
||||||
|
_column_51,
|
||||||
|
_column_52,
|
||||||
|
_column_53,
|
||||||
|
_column_54,
|
||||||
|
_column_55,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_36, _column_60],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||||
|
columns: [_column_60, _column_25, _column_61],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape11 memoryEntity = Shape11(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_18,
|
||||||
|
_column_15,
|
||||||
|
_column_8,
|
||||||
|
_column_62,
|
||||||
|
_column_63,
|
||||||
|
_column_64,
|
||||||
|
_column_65,
|
||||||
|
_column_66,
|
||||||
|
_column_67,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape12 memoryAssetEntity = Shape12(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||||
|
columns: [_column_36, _column_68],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape14 personEntity = Shape14(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'person_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_1,
|
||||||
|
_column_69,
|
||||||
|
_column_71,
|
||||||
|
_column_72,
|
||||||
|
_column_73,
|
||||||
|
_column_74,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape15 assetFaceEntity = Shape15(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'asset_face_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_36,
|
||||||
|
_column_76,
|
||||||
|
_column_77,
|
||||||
|
_column_78,
|
||||||
|
_column_79,
|
||||||
|
_column_80,
|
||||||
|
_column_81,
|
||||||
|
_column_82,
|
||||||
|
_column_83,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape18 storeEntity = Shape18(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'store_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_87, _column_88, _column_89],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxLatLng = i1.Index(
|
||||||
|
'idx_lat_lng',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape22 extends i0.VersionedTable {
|
||||||
|
Shape22({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<String> get assetId =>
|
||||||
|
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get albumId =>
|
||||||
|
columnsByName['album_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<bool> get marker_ =>
|
||||||
|
columnsByName['marker']! as i1.GeneratedColumn<bool>;
|
||||||
|
}
|
||||||
|
|
||||||
i0.MigrationStepWithVersion migrationSteps({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
@@ -4280,6 +4669,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||||
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
||||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
@@ -4328,6 +4718,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from9To10(migrator, schema);
|
await from9To10(migrator, schema);
|
||||||
return 10;
|
return 10;
|
||||||
|
case 10:
|
||||||
|
final schema = Schema11(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from10To11(migrator, schema);
|
||||||
|
return 11;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
@@ -4344,6 +4739,7 @@ i1.OnUpgrade stepByStep({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||||
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
||||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
@@ -4355,5 +4751,6 @@ i1.OnUpgrade stepByStep({
|
|||||||
from7To8: from7To8,
|
from7To8: from7To8,
|
||||||
from8To9: from8To9,
|
from8To9: from8To9,
|
||||||
from9To10: from9To10,
|
from9To10: from9To10,
|
||||||
|
from10To11: from10To11,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,17 +72,33 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
final deleteSmt = _db.localAssetEntity.delete();
|
return _db.transaction(() async {
|
||||||
deleteSmt.where((localAsset) {
|
await _db.managers.localAlbumAssetEntity
|
||||||
final subQuery = _db.localAlbumAssetEntity.selectOnly()
|
.filter((row) => row.albumId.id.equals(albumId))
|
||||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
.update((album) => album(marker_: const Value(true)));
|
||||||
..join([innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id))]);
|
|
||||||
subQuery.where(
|
await _db.batch((batch) {
|
||||||
_db.localAlbumEntity.id.equals(albumId) & _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep),
|
for (final assetId in assetIdsToKeep) {
|
||||||
);
|
batch.update(
|
||||||
return localAsset.id.isInQuery(subQuery);
|
_db.localAlbumAssetEntity,
|
||||||
|
const LocalAlbumAssetEntityCompanion(marker_: Value(null)),
|
||||||
|
where: (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final query = _db.localAssetEntity.delete()
|
||||||
|
..where(
|
||||||
|
(row) => row.id.isInQuery(
|
||||||
|
_db.localAlbumAssetEntity.selectOnly()
|
||||||
|
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||||
|
..where(
|
||||||
|
_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAlbumAssetEntity.marker_.isNotNull(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await query.go();
|
||||||
});
|
});
|
||||||
await deleteSmt.go();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> upsert(
|
Future<void> upsert(
|
||||||
@@ -198,10 +214,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
// List<String>
|
// List<String>
|
||||||
await _db.batch((batch) async {
|
await _db.batch((batch) async {
|
||||||
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
|
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
|
||||||
batch.deleteWhere(
|
for (final albumId in albumIds.cast<String?>().nonNulls) {
|
||||||
_db.localAlbumAssetEntity,
|
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId));
|
||||||
(f) => f.albumId.isNotIn(albumIds.cast<String?>().nonNulls) & f.assetId.equals(assetId),
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await _db.batch((batch) async {
|
await _db.batch((batch) async {
|
||||||
@@ -288,12 +303,14 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
return transaction(() async {
|
return transaction(() async {
|
||||||
if (assetsToUnLink.isNotEmpty) {
|
if (assetsToUnLink.isNotEmpty) {
|
||||||
await _db.batch(
|
await _db.batch((batch) {
|
||||||
(batch) => batch.deleteWhere(
|
for (final assetId in assetsToUnLink) {
|
||||||
_db.localAlbumAssetEntity,
|
batch.deleteWhere(
|
||||||
(f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId),
|
_db.localAlbumAssetEntity,
|
||||||
),
|
(row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await _deleteAssets(assetsToDelete);
|
await _deleteAssets(assetsToDelete);
|
||||||
@@ -320,7 +337,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch((batch) {
|
return _db.batch((batch) {
|
||||||
batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
|
for (final id in ids) {
|
||||||
|
batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
@@ -36,17 +35,17 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
|
Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
|
||||||
|
|
||||||
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
|
Future<void> updateHashes(Map<String, String> hashes) {
|
||||||
if (hashes.isEmpty) {
|
if (hashes.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch((batch) async {
|
return _db.batch((batch) async {
|
||||||
for (final asset in hashes) {
|
for (final entry in hashes.entries) {
|
||||||
batch.update(
|
batch.update(
|
||||||
_db.localAssetEntity,
|
_db.localAssetEntity,
|
||||||
LocalAssetEntityCompanion(checksum: Value(asset.checksum)),
|
LocalAssetEntityCompanion(checksum: Value(entry.value)),
|
||||||
where: (e) => e.id.equals(asset.id),
|
where: (e) => e.id.equals(entry.key),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,8 +57,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch((batch) {
|
return _db.batch((batch) {
|
||||||
for (final slice in ids.slices(32000)) {
|
for (final id in ids) {
|
||||||
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice));
|
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.equals(id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,8 +166,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> removeAssets(String albumId, List<String> assetIds) {
|
Future<void> removeAssets(String albumId, List<String> assetIds) {
|
||||||
return _db.remoteAlbumAssetEntity.deleteWhere((tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds));
|
return _db.batch((batch) {
|
||||||
|
for (final assetId in assetIds) {
|
||||||
|
batch.deleteWhere(
|
||||||
|
_db.remoteAlbumAssetEntity,
|
||||||
|
(row) => row.albumId.equals(albumId) & row.assetId.equals(assetId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
||||||
|
|||||||
@@ -62,12 +62,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
|
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
|
||||||
if (asset.stackId == null) {
|
final stackId = asset.stackId;
|
||||||
return Future.value([]);
|
if (stackId == null) {
|
||||||
|
return Future.value(const []);
|
||||||
}
|
}
|
||||||
|
|
||||||
final query = _db.remoteAssetEntity.select()
|
final query = _db.remoteAssetEntity.select()
|
||||||
..where((row) => row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not())
|
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
|
||||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
|
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
|
||||||
|
|
||||||
return query.map((row) => row.toDto()).get();
|
return query.map((row) => row.toDto()).get();
|
||||||
@@ -159,7 +160,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> delete(List<String> ids) {
|
Future<void> delete(List<String> ids) {
|
||||||
return _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(ids));
|
return _db.batch((batch) {
|
||||||
|
for (final id in ids) {
|
||||||
|
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateLocation(List<String> ids, LatLng location) {
|
Future<void> updateLocation(List<String> ids, LatLng location) {
|
||||||
@@ -198,7 +203,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
.map((row) => row.id)
|
.map((row) => row.id)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
|
await _db.batch((batch) {
|
||||||
|
for (final stackId in stackIds) {
|
||||||
|
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await _db.batch((batch) {
|
await _db.batch((batch) {
|
||||||
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
|
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
|
||||||
@@ -218,15 +227,21 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> unStack(List<String> stackIds) {
|
Future<void> unStack(List<String> stackIds) {
|
||||||
return _db.transaction(() async {
|
return _db.transaction(() async {
|
||||||
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
|
await _db.batch((batch) {
|
||||||
|
for (final stackId in stackIds) {
|
||||||
|
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: delete this after adding foreign key on stackId
|
// TODO: delete this after adding foreign key on stackId
|
||||||
await _db.batch((batch) {
|
await _db.batch((batch) {
|
||||||
batch.update(
|
for (final stackId in stackIds) {
|
||||||
_db.remoteAssetEntity,
|
batch.update(
|
||||||
const RemoteAssetEntityCompanion(stackId: Value(null)),
|
_db.remoteAssetEntity,
|
||||||
where: (e) => e.stackId.isIn(stackIds),
|
const RemoteAssetEntityCompanion(stackId: Value(null)),
|
||||||
);
|
where: (e) => e.stackId.equals(stackId),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
|
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.userEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId)));
|
await _db.batch((batch) {
|
||||||
|
for (final user in data) {
|
||||||
|
batch.deleteWhere(_db.userEntity, (row) => row.id.equals(user.userId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: SyncUserDeleteV1', error, stack);
|
_logger.severe('Error: SyncUserDeleteV1', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -158,7 +162,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async {
|
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async {
|
||||||
try {
|
try {
|
||||||
await _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId)));
|
await _db.batch((batch) {
|
||||||
|
for (final asset in data) {
|
||||||
|
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(asset.assetId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
|
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -243,7 +251,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.remoteAlbumEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId)));
|
await _db.batch((batch) {
|
||||||
|
for (final album in data) {
|
||||||
|
batch.deleteWhere(_db.remoteAlbumEntity, (row) => row.id.equals(album.albumId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteAlbumsV1', error, stack);
|
_logger.severe('Error: deleteAlbumsV1', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -379,7 +391,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
|
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.memoryEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.memoryId)));
|
await _db.batch((batch) {
|
||||||
|
for (final memory in data) {
|
||||||
|
batch.deleteWhere(_db.memoryEntity, (row) => row.id.equals(memory.memoryId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteMemoriesV1', error, stack);
|
_logger.severe('Error: deleteMemoriesV1', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -443,7 +459,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async {
|
Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async {
|
||||||
try {
|
try {
|
||||||
await _db.stackEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.stackId)));
|
await _db.batch((batch) {
|
||||||
|
for (final stack in data) {
|
||||||
|
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stack.stackId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
|
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -11,13 +9,12 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
@@ -29,8 +26,6 @@ class DriftBackupPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||||
Timer? _countPoller;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -39,42 +34,16 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
_startCountPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startCountPolling() {
|
|
||||||
_countPoller?.cancel();
|
|
||||||
_countPoller = Timer.periodic(const Duration(seconds: 5), (timer) async {
|
|
||||||
if (!mounted) {
|
|
||||||
timer.cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final currentUser = ref.read(currentUserProvider);
|
|
||||||
if (currentUser == null) {
|
|
||||||
timer.cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||||
|
await ref.read(backgroundSyncProvider).syncRemote();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stopCountPolling() {
|
|
||||||
_countPoller?.cancel();
|
|
||||||
_countPoller = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_stopCountPolling();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final selectedAlbum = ref
|
final selectedAlbum = ref
|
||||||
@@ -83,7 +52,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
|
||||||
|
|
||||||
Future<void> startBackup() async {
|
Future<void> startBackup() async {
|
||||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||||
@@ -91,15 +59,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await backgroundManager.syncRemote();
|
|
||||||
await backupNotifier.getBackupStatus(currentUser.id);
|
await backupNotifier.getBackupStatus(currentUser.id);
|
||||||
await backupNotifier.startBackup(currentUser.id);
|
await backupNotifier.startBackup(currentUser.id);
|
||||||
_startCountPolling();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopBackup() async {
|
Future<void> stopBackup() async {
|
||||||
await backupNotifier.cancel();
|
await backupNotifier.cancel();
|
||||||
_stopCountPolling();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -276,11 +241,13 @@ class _BackupCard extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final backupCount = ref.watch(driftBackupProvider.select((p) => p.backupCount));
|
final backupCount = ref.watch(driftBackupProvider.select((p) => p.backupCount));
|
||||||
|
final syncStatus = ref.watch(syncStatusProvider);
|
||||||
|
|
||||||
return BackupInfoCard(
|
return BackupInfoCard(
|
||||||
title: "backup_controller_page_backup".tr(),
|
title: "backup_controller_page_backup".tr(),
|
||||||
subtitle: "backup_controller_page_backup_sub".tr(),
|
subtitle: "backup_controller_page_backup_sub".tr(),
|
||||||
info: backupCount.toString(),
|
info: backupCount.toString(),
|
||||||
|
isLoading: syncStatus.isRemoteSyncing,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,10 +258,13 @@ class _RemainderCard extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
|
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
|
||||||
|
final syncStatus = ref.watch(syncStatusProvider);
|
||||||
|
|
||||||
return BackupInfoCard(
|
return BackupInfoCard(
|
||||||
title: "backup_controller_page_remainder".tr(),
|
title: "backup_controller_page_remainder".tr(),
|
||||||
subtitle: "backup_controller_page_remainder_sub".tr(),
|
subtitle: "backup_controller_page_remainder_sub".tr(),
|
||||||
info: remainderCount.toString(),
|
info: remainderCount.toString(),
|
||||||
|
isLoading: syncStatus.isRemoteSyncing,
|
||||||
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
|
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
@@ -5,12 +6,14 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
|
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
|
||||||
@@ -64,16 +67,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||||||
});
|
});
|
||||||
await _handleLinkedAlbumFuture;
|
await _handleLinkedAlbumFuture;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart backup if total count changed and backup is enabled
|
|
||||||
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
|
||||||
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
|
|
||||||
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
|
||||||
|
|
||||||
if (totalChanged && isBackupEnabled) {
|
|
||||||
await ref.read(driftBackupProvider.notifier).cancel();
|
|
||||||
await ref.read(driftBackupProvider.notifier).startBackup(user.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -102,6 +95,27 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||||||
onPopInvokedWithResult: (didPop, _) async {
|
onPopInvokedWithResult: (didPop, _) async {
|
||||||
if (!didPop) {
|
if (!didPop) {
|
||||||
await _handlePagePopped();
|
await _handlePagePopped();
|
||||||
|
|
||||||
|
final user = ref.read(currentUserProvider);
|
||||||
|
if (user == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||||
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
|
||||||
|
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||||
|
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
|
||||||
|
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||||
|
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||||
|
final nativeSync = ref.read(nativeSyncApiProvider);
|
||||||
|
if (totalChanged) {
|
||||||
|
// Waits for hashing to be cancelled before starting a new one
|
||||||
|
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
||||||
|
if (isBackupEnabled) {
|
||||||
|
unawaited(backupNotifier.cancel().whenComplete(() => backupNotifier.startBackup(user.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+79
@@ -25,6 +25,57 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
|
|||||||
return <Object?>[error.code, error.message, error.details];
|
return <Object?>[error.code, error.message, error.details];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _deepEquals(Object? a, Object? b) {
|
||||||
|
if (a is List && b is List) {
|
||||||
|
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||||
|
}
|
||||||
|
if (a is Map && b is Map) {
|
||||||
|
return a.length == b.length &&
|
||||||
|
a.entries.every(
|
||||||
|
(MapEntry<Object?, Object?> entry) =>
|
||||||
|
(b as Map<Object?, Object?>).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return a == b;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundWorkerSettings {
|
||||||
|
BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
|
||||||
|
|
||||||
|
bool requiresCharging;
|
||||||
|
|
||||||
|
int minimumDelaySeconds;
|
||||||
|
|
||||||
|
List<Object?> _toList() {
|
||||||
|
return <Object?>[requiresCharging, minimumDelaySeconds];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static BackgroundWorkerSettings decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
return BackgroundWorkerSettings(requiresCharging: result[0]! as bool, minimumDelaySeconds: result[1]! as int);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! BackgroundWorkerSettings || other.runtimeType != runtimeType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _deepEquals(encode(), other.encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
int get hashCode => Object.hashAll(_toList());
|
||||||
|
}
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -32,6 +83,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
if (value is int) {
|
if (value is int) {
|
||||||
buffer.putUint8(4);
|
buffer.putUint8(4);
|
||||||
buffer.putInt64(value);
|
buffer.putInt64(value);
|
||||||
|
} else if (value is BackgroundWorkerSettings) {
|
||||||
|
buffer.putUint8(129);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(buffer, value);
|
super.writeValue(buffer, value);
|
||||||
}
|
}
|
||||||
@@ -40,6 +94,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
@override
|
@override
|
||||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 129:
|
||||||
|
return BackgroundWorkerSettings.decode(readValue(buffer)!);
|
||||||
default:
|
default:
|
||||||
return super.readValueOfType(type, buffer);
|
return super.readValueOfType(type, buffer);
|
||||||
}
|
}
|
||||||
@@ -82,6 +138,29 @@ class BackgroundWorkerFgHostApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> configure(BackgroundWorkerSettings settings) async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[settings]);
|
||||||
|
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> disable() async {
|
Future<void> disable() async {
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';
|
||||||
|
|||||||
+71
-4
@@ -205,6 +205,45 @@ class SyncDelta {
|
|||||||
int get hashCode => Object.hashAll(_toList());
|
int get hashCode => Object.hashAll(_toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class HashResult {
|
||||||
|
HashResult({required this.assetId, this.error, this.hash});
|
||||||
|
|
||||||
|
String assetId;
|
||||||
|
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
String? hash;
|
||||||
|
|
||||||
|
List<Object?> _toList() {
|
||||||
|
return <Object?>[assetId, error, hash];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static HashResult decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! HashResult || other.runtimeType != runtimeType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _deepEquals(encode(), other.encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
int get hashCode => Object.hashAll(_toList());
|
||||||
|
}
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -221,6 +260,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
} else if (value is SyncDelta) {
|
} else if (value is SyncDelta) {
|
||||||
buffer.putUint8(131);
|
buffer.putUint8(131);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
|
} else if (value is HashResult) {
|
||||||
|
buffer.putUint8(132);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(buffer, value);
|
super.writeValue(buffer, value);
|
||||||
}
|
}
|
||||||
@@ -235,6 +277,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
return PlatformAlbum.decode(readValue(buffer)!);
|
return PlatformAlbum.decode(readValue(buffer)!);
|
||||||
case 131:
|
case 131:
|
||||||
return SyncDelta.decode(readValue(buffer)!);
|
return SyncDelta.decode(readValue(buffer)!);
|
||||||
|
case 132:
|
||||||
|
return HashResult.decode(readValue(buffer)!);
|
||||||
default:
|
default:
|
||||||
return super.readValueOfType(type, buffer);
|
return super.readValueOfType(type, buffer);
|
||||||
}
|
}
|
||||||
@@ -468,15 +512,15 @@ class NativeSyncApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Uint8List?>> hashPaths(List<String> paths) async {
|
Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async {
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
|
||||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[paths]);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetIds, allowNetworkAccess]);
|
||||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
if (pigeonVar_replyList == null) {
|
if (pigeonVar_replyList == null) {
|
||||||
throw _createConnectionError(pigeonVar_channelName);
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
@@ -492,7 +536,30 @@ class NativeSyncApi {
|
|||||||
message: 'Host platform returned null value for non-null return value.',
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<Uint8List?>();
|
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<HashResult>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelHashing() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.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';
|
||||||
|
import 'package:immich_mobile/pages/common/download_panel.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class DownloadInfoPage extends ConsumerWidget {
|
||||||
|
const DownloadInfoPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList();
|
||||||
|
|
||||||
|
onCancelDownload(String id) {
|
||||||
|
ref.watch(downloadStateProvider.notifier).cancelDownload(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text("download".t(context: context)),
|
||||||
|
actions: [],
|
||||||
|
),
|
||||||
|
body: ListView.builder(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: tasks.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final task = tasks[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||||
|
child: DownloadTaskTile(
|
||||||
|
progress: task.value.progress,
|
||||||
|
fileName: task.value.fileName,
|
||||||
|
status: task.value.status,
|
||||||
|
onCancelDownload: () => onCancelDownload(task.key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
persistentFooterButtons: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
tasks.map((e) => e.key).forEach(onCancelDownload);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(side: BorderSide(color: context.colorScheme.primary)),
|
||||||
|
child: Text(
|
||||||
|
'clear_all'.t(context: context),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -633,7 +633,7 @@ class _SearchResultGrid extends ConsumerWidget {
|
|||||||
groupBy: GroupAssetsBy.none,
|
groupBy: GroupAssetsBy.none,
|
||||||
appBar: null,
|
appBar: null,
|
||||||
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
|
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
|
||||||
withScrubber: false,
|
snapToMonth: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,54 +1,45 @@
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class DownloadActionButton extends ConsumerWidget {
|
class DownloadActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool menuItem;
|
||||||
|
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
|
||||||
|
|
||||||
const DownloadActionButton({super.key, required this.source});
|
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).downloadAll(source);
|
try {
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
await ref.read(actionProvider.notifier).downloadAll(source);
|
||||||
|
|
||||||
if (!context.mounted) {
|
Future.delayed(const Duration(seconds: 1), () async {
|
||||||
return;
|
await backgroundSyncManager.syncLocal();
|
||||||
}
|
await backgroundSyncManager.hashAssets();
|
||||||
|
});
|
||||||
if (!result.success) {
|
} finally {
|
||||||
ImmichToast.show(
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
context: context,
|
|
||||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: ToastType.error,
|
|
||||||
);
|
|
||||||
} else if (result.count > 0) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: 'download_action_prompt'.t(context: context, args: {'count': result.count.toString()}),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: ToastType.success,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final backgroundManager = ref.watch(backgroundSyncProvider);
|
||||||
|
|
||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.download,
|
iconData: Icons.download,
|
||||||
maxWidth: 95,
|
maxWidth: 95,
|
||||||
label: "download".t(context: context),
|
label: "download".t(context: context),
|
||||||
onPressed: () => _onTap(context, ref),
|
menuItem: menuItem,
|
||||||
|
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
class DownloadStatusFloatingButton extends ConsumerWidget {
|
||||||
|
const DownloadStatusFloatingButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final shouldShow = ref.watch(downloadStateProvider.select((state) => state.showProgress));
|
||||||
|
final itemCount = ref.watch(downloadStateProvider.select((state) => state.taskProgress.length));
|
||||||
|
final isDownloading = ref
|
||||||
|
.watch(downloadStateProvider.select((state) => state.taskProgress))
|
||||||
|
.values
|
||||||
|
.where((element) => element.progress != 1)
|
||||||
|
.isNotEmpty;
|
||||||
|
|
||||||
|
return shouldShow
|
||||||
|
? Badge.count(
|
||||||
|
count: itemCount,
|
||||||
|
textColor: context.colorScheme.onPrimary,
|
||||||
|
backgroundColor: context.colorScheme.primary,
|
||||||
|
child: FloatingActionButton(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
|
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
|
||||||
|
),
|
||||||
|
backgroundColor: context.isDarkTheme
|
||||||
|
? context.colorScheme.surfaceContainer
|
||||||
|
: context.colorScheme.surfaceBright,
|
||||||
|
elevation: 2,
|
||||||
|
onPressed: () {
|
||||||
|
context.pushRoute(const DownloadInfoRoute());
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
alignment: AlignmentDirectional.center,
|
||||||
|
children: [
|
||||||
|
isDownloading
|
||||||
|
? Icon(Icons.downloading_rounded, color: context.colorScheme.primary, size: 28)
|
||||||
|
: Icon(
|
||||||
|
Icons.download_done,
|
||||||
|
color: context.isDarkTheme ? Colors.green[200] : Colors.green[400],
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
if (isDownloading)
|
||||||
|
const SizedBox(
|
||||||
|
height: 31,
|
||||||
|
width: 31,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
value: null, // Indeterminate progress
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
|
||||||
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset?> {
|
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset> {
|
||||||
@override
|
@override
|
||||||
Future<List<RemoteAsset>> build(BaseAsset? asset) async {
|
Future<List<RemoteAsset>> build(BaseAsset asset) {
|
||||||
if (asset == null || asset is! RemoteAsset || asset.stackId == null) {
|
if (asset is! RemoteAsset || asset.stackId == null) {
|
||||||
return const [];
|
return Future.value(const []);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ref.watch(assetServiceProvider).getStack(asset);
|
return ref.watch(assetServiceProvider).getStack(asset);
|
||||||
@@ -14,4 +14,4 @@ class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAs
|
|||||||
}
|
}
|
||||||
|
|
||||||
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
|
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
|
||||||
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset?>(StackChildrenNotifier.new);
|
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset>(StackChildrenNotifier.new);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
|
||||||
class AssetStackRow extends ConsumerWidget {
|
class AssetStackRow extends ConsumerWidget {
|
||||||
@@ -11,27 +11,25 @@ class AssetStackRow extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
if (asset == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
if (!showControls) {
|
|
||||||
opacity = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
|
||||||
|
if (stackChildren == null || stackChildren.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
|
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0;
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: opacity < 255,
|
ignoring: opacity < 255,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: opacity / 255,
|
opacity: opacity / 255,
|
||||||
duration: Durations.short2,
|
duration: Durations.short2,
|
||||||
child: ref
|
child: _StackList(stack: stackChildren),
|
||||||
.watch(stackChildrenNotifier(asset))
|
|
||||||
.when(
|
|
||||||
data: (state) => SizedBox.square(dimension: 80, child: _StackList(stack: state)),
|
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
|
||||||
loading: () => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,58 +42,77 @@ class _StackList extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return ListView.builder(
|
return Center(
|
||||||
scrollDirection: Axis.horizontal,
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 30),
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: stack.length,
|
child: Padding(
|
||||||
itemBuilder: (ctx, index) {
|
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
|
||||||
final asset = stack[index];
|
child: Row(
|
||||||
return Padding(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
padding: const EdgeInsets.only(right: 5),
|
spacing: 5.0,
|
||||||
child: GestureDetector(
|
children: List.generate(stack.length, (i) {
|
||||||
onTap: () {
|
final asset = stack[i];
|
||||||
ref.read(assetViewerProvider.notifier).setStackIndex(index);
|
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
}),
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
height: 60,
|
|
||||||
width: 60,
|
|
||||||
decoration: index == ref.watch(assetViewerProvider.select((s) => s.stackIndex))
|
|
||||||
? const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
|
||||||
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
|
|
||||||
)
|
|
||||||
: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
|
||||||
border: null,
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
Image(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
image: getThumbnailImageProvider(remoteId: asset.id, size: const Size.square(60)),
|
|
||||||
),
|
|
||||||
if (asset.isVideo)
|
|
||||||
const Icon(
|
|
||||||
Icons.play_circle_outline_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 16,
|
|
||||||
shadows: [
|
|
||||||
Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StackItem extends ConsumerStatefulWidget {
|
||||||
|
final RemoteAsset asset;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
const _StackItem({super.key, required this.asset, required this.index});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_StackItem> createState() => _StackItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StackItemState extends ConsumerState<_StackItem> {
|
||||||
|
void _onTap() {
|
||||||
|
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
|
||||||
|
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const playIcon = Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.play_circle_outline_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 16,
|
||||||
|
shadows: [Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedDecoration = BoxDecoration(
|
||||||
|
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||||
|
);
|
||||||
|
const unselectedDecoration = BoxDecoration(
|
||||||
|
border: Border.fromBorderSide(BorderSide(color: Colors.grey, width: 0.5)),
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget thumbnail = Thumbnail.fromAsset(asset: widget.asset, size: const Size(60, 40));
|
||||||
|
if (widget.asset.isVideo) {
|
||||||
|
thumbnail = Stack(children: [thumbnail, playIcon]);
|
||||||
|
}
|
||||||
|
thumbnail = ClipRRect(borderRadius: const BorderRadius.all(Radius.circular(10)), child: thumbnail);
|
||||||
|
final isSelected = ref.watch(assetViewerProvider.select((s) => s.stackIndex == widget.index));
|
||||||
|
return SizedBox(
|
||||||
|
width: 60,
|
||||||
|
height: 40,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _onTap,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: isSelected ? selectedDecoration : unselectedDecoration,
|
||||||
|
position: DecorationPosition.foreground,
|
||||||
|
child: thumbnail,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
@@ -61,6 +62,15 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||||||
ConsumerState createState() => _AssetViewerState();
|
ConsumerState createState() => _AssetViewerState();
|
||||||
|
|
||||||
static void setAsset(WidgetRef ref, BaseAsset asset) {
|
static void setAsset(WidgetRef ref, BaseAsset asset) {
|
||||||
|
ref.read(assetViewerProvider.notifier).reset();
|
||||||
|
_setAsset(ref, asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void changeAsset(WidgetRef ref, BaseAsset asset) {
|
||||||
|
_setAsset(ref, asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||||
// Always holds the current asset from the timeline
|
// Always holds the current asset from the timeline
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||||
// The currentAssetNotifier actually holds the current asset that is displayed
|
// The currentAssetNotifier actually holds the current asset that is displayed
|
||||||
@@ -107,6 +117,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
ImageStream? _prevPreCacheStream;
|
ImageStream? _prevPreCacheStream;
|
||||||
ImageStream? _nextPreCacheStream;
|
ImageStream? _nextPreCacheStream;
|
||||||
|
|
||||||
|
KeepAliveLink? _stackChildrenKeepAlive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -117,6 +129,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
||||||
reloadSubscription = EventStream.shared.listen(_onEvent);
|
reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||||
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||||
|
final asset = ref.read(currentAssetNotifier);
|
||||||
|
if (asset != null) {
|
||||||
|
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -128,6 +144,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
_prevPreCacheStream?.removeListener(_dummyListener);
|
_prevPreCacheStream?.removeListener(_dummyListener);
|
||||||
_nextPreCacheStream?.removeListener(_dummyListener);
|
_nextPreCacheStream?.removeListener(_dummyListener);
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
_stackChildrenKeepAlive?.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +205,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetViewer.setAsset(ref, asset);
|
widget.changeAsset(ref, asset);
|
||||||
_precacheAssets(index);
|
_precacheAssets(index);
|
||||||
_handleCasting();
|
_handleCasting();
|
||||||
|
_stackChildrenKeepAlive?.close();
|
||||||
|
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleCasting() {
|
void _handleCasting() {
|
||||||
@@ -518,7 +537,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
BaseAsset displayAsset = asset;
|
BaseAsset displayAsset = asset;
|
||||||
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
||||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||||
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
|
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
|
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
|
||||||
@@ -631,20 +650,25 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
appBar: const ViewerTopAppBar(),
|
appBar: const ViewerTopAppBar(),
|
||||||
extendBody: true,
|
extendBody: true,
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
body: PhotoViewGallery.builder(
|
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||||
gaplessPlayback: true,
|
body: Stack(
|
||||||
loadingBuilder: _placeholderBuilder,
|
children: [
|
||||||
pageController: pageController,
|
PhotoViewGallery.builder(
|
||||||
scrollPhysics: CurrentPlatform.isIOS
|
gaplessPlayback: true,
|
||||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
loadingBuilder: _placeholderBuilder,
|
||||||
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
pageController: pageController,
|
||||||
itemCount: totalAssets,
|
scrollPhysics: CurrentPlatform.isIOS
|
||||||
onPageChanged: _onPageChanged,
|
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||||
onPageBuild: _onPageBuild,
|
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
||||||
scaleStateChangedCallback: _onScaleStateChanged,
|
itemCount: totalAssets,
|
||||||
builder: _assetBuilder,
|
onPageChanged: _onPageChanged,
|
||||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
onPageBuild: _onPageBuild,
|
||||||
enablePanAlways: true,
|
scaleStateChangedCallback: _onScaleStateChanged,
|
||||||
|
builder: _assetBuilder,
|
||||||
|
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||||
|
enablePanAlways: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: showingBottomSheet
|
bottomNavigationBar: showingBottomSheet
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
|
|||||||
@@ -68,12 +68,16 @@ class AssetViewerState {
|
|||||||
stackIndex.hashCode;
|
stackIndex.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
|
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||||
@override
|
@override
|
||||||
AssetViewerState build() {
|
AssetViewerState build() {
|
||||||
return const AssetViewerState();
|
return const AssetViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
state = const AssetViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
void setAsset(BaseAsset? asset) {
|
void setAsset(BaseAsset? asset) {
|
||||||
if (asset == state.currentAsset) {
|
if (asset == state.currentAsset) {
|
||||||
return;
|
return;
|
||||||
@@ -117,6 +121,4 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final assetViewerProvider = AutoDisposeNotifierProvider<AssetViewerStateNotifier, AssetViewerState>(
|
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||||
AssetViewerStateNotifier.new,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||||
@@ -56,6 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
|
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||||
if (album != null && album.isActivityEnabled && album.isShared)
|
if (album != null && album.isActivityEnabled && album.isShared)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
initialChildSize: 0.45,
|
initialChildSize: widget.minChildSize ?? 0.15,
|
||||||
minChildSize: widget.minChildSize,
|
minChildSize: widget.minChildSize,
|
||||||
maxChildSize: 0.85,
|
maxChildSize: 0.85,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
initialChildSize: 0.45,
|
initialChildSize: 0.22,
|
||||||
|
minChildSize: 0.22,
|
||||||
maxChildSize: 0.85,
|
maxChildSize: 0.85,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
actions: [
|
actions: [
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'dart:ui';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/loaders/image_request.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/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||||
@@ -88,13 +90,26 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
}
|
}
|
||||||
|
|
||||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||||
final request = this.request = LocalImageRequest(
|
var request = this.request = LocalImageRequest(
|
||||||
localId: key.id,
|
localId: key.id,
|
||||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||||
assetType: key.assetType,
|
assetType: key.assetType,
|
||||||
);
|
);
|
||||||
|
|
||||||
yield* loadRequest(request, decode);
|
yield* loadRequest(request, decode);
|
||||||
|
|
||||||
|
if (!Store.get(StoreKey.loadOriginal, false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
evict();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request = this.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
|
||||||
|
|
||||||
|
yield* loadRequest(request, decode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ class _ThumbnailRenderBox extends RenderBox {
|
|||||||
image: _previousImage!,
|
image: _previousImage!,
|
||||||
fit: _fit,
|
fit: _fit,
|
||||||
filterQuality: FilterQuality.low,
|
filterQuality: FilterQuality.low,
|
||||||
opacity: 1.0 - _fadeValue,
|
opacity: 1.0,
|
||||||
);
|
);
|
||||||
} else if (_image == null || _fadeValue < 1.0) {
|
} else if (_image == null || _fadeValue < 1.0) {
|
||||||
final paint = Paint()..shader = _placeholderGradient.createShader(rect);
|
final paint = Paint()..shader = _placeholderGradient.createShader(rect);
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
if (isScrubbing) {
|
if (isScrubbing) {
|
||||||
return _buildPlaceholder(context);
|
return _buildPlaceholder(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||||
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
|
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,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/segment.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
import 'package:intl/intl.dart' hide TextDirection;
|
import 'package:intl/intl.dart' hide TextDirection;
|
||||||
|
|
||||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||||
@@ -30,6 +31,11 @@ class Scrubber extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
final double? monthSegmentSnappingOffset;
|
final double? monthSegmentSnappingOffset;
|
||||||
|
|
||||||
|
final bool snapToMonth;
|
||||||
|
|
||||||
|
/// Whether an app bar is present, affects coordinate calculations
|
||||||
|
final bool hasAppBar;
|
||||||
|
|
||||||
Scrubber({
|
Scrubber({
|
||||||
super.key,
|
super.key,
|
||||||
Key? scrollThumbKey,
|
Key? scrollThumbKey,
|
||||||
@@ -38,6 +44,8 @@ class Scrubber extends ConsumerStatefulWidget {
|
|||||||
this.topPadding = 0,
|
this.topPadding = 0,
|
||||||
this.bottomPadding = 0,
|
this.bottomPadding = 0,
|
||||||
this.monthSegmentSnappingOffset,
|
this.monthSegmentSnappingOffset,
|
||||||
|
this.snapToMonth = true,
|
||||||
|
this.hasAppBar = true,
|
||||||
required this.child,
|
required this.child,
|
||||||
}) : assert(child.scrollDirection == Axis.vertical);
|
}) : assert(child.scrollDirection == Axis.vertical);
|
||||||
|
|
||||||
@@ -81,6 +89,8 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
bool _isDragging = false;
|
bool _isDragging = false;
|
||||||
List<_Segment> _segments = [];
|
List<_Segment> _segments = [];
|
||||||
int _monthCount = 0;
|
int _monthCount = 0;
|
||||||
|
DateTime? _currentScrubberDate;
|
||||||
|
Debouncer? _scrubberDebouncer;
|
||||||
|
|
||||||
late AnimationController _thumbAnimationController;
|
late AnimationController _thumbAnimationController;
|
||||||
Timer? _fadeOutTimer;
|
Timer? _fadeOutTimer;
|
||||||
@@ -133,6 +143,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
_thumbAnimationController.dispose();
|
_thumbAnimationController.dispose();
|
||||||
_labelAnimationController.dispose();
|
_labelAnimationController.dispose();
|
||||||
_fadeOutTimer?.cancel();
|
_fadeOutTimer?.cancel();
|
||||||
|
_scrubberDebouncer?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,11 +187,25 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragStart(DragStartDetails _) {
|
void _onScrubberDateChanged(DateTime date) {
|
||||||
if (_monthCount >= kMinMonthsToEnableScrubberSnap) {
|
if (_currentScrubberDate != date) {
|
||||||
|
// Date changed, immediately set scrubbing to true
|
||||||
|
_currentScrubberDate = date;
|
||||||
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
||||||
}
|
|
||||||
|
|
||||||
|
// Initialize debouncer if needed
|
||||||
|
_scrubberDebouncer ??= Debouncer(interval: const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
// Debounce setting scrubbing to false
|
||||||
|
_scrubberDebouncer!.run(() {
|
||||||
|
if (_currentScrubberDate == date) {
|
||||||
|
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragStart(DragStartDetails _) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
_labelAnimationController.forward();
|
_labelAnimationController.forward();
|
||||||
@@ -206,10 +231,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
if (_lastLabel != label) {
|
if (_lastLabel != label) {
|
||||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||||
_lastLabel = label;
|
_lastLabel = label;
|
||||||
|
|
||||||
|
// Notify timeline state of the new scrubber date position
|
||||||
|
if (_monthCount >= kMinMonthsToEnableScrubberSnap) {
|
||||||
|
_onScrubberDateChanged(nearestMonthSegment.date);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_monthCount < kMinMonthsToEnableScrubberSnap) {
|
if (_monthCount < kMinMonthsToEnableScrubberSnap || !widget.snapToMonth) {
|
||||||
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
|
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
|
||||||
setState(() {
|
setState(() {
|
||||||
_thumbTopOffset = dragPosition;
|
_thumbTopOffset = dragPosition;
|
||||||
@@ -236,14 +266,28 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
/// - If user drags to global Y position that's 100 pixels from the top
|
/// - If user drags to global Y position that's 100 pixels from the top
|
||||||
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
|
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
|
||||||
double _calculateDragPosition(DragUpdateDetails details) {
|
double _calculateDragPosition(DragUpdateDetails details) {
|
||||||
|
if (widget.hasAppBar) {
|
||||||
|
final dragAreaTop = widget.topPadding;
|
||||||
|
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
|
||||||
|
final dragAreaHeight = dragAreaBottom - dragAreaTop;
|
||||||
|
|
||||||
|
final relativePosition = details.globalPosition.dy - dragAreaTop;
|
||||||
|
|
||||||
|
// Make sure the position stays within the scrubber's bounds
|
||||||
|
return relativePosition.clamp(0.0, dragAreaHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the local position relative to the gesture detector
|
||||||
|
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
if (renderBox != null) {
|
||||||
|
final localPosition = renderBox.globalToLocal(details.globalPosition);
|
||||||
|
return localPosition.dy.clamp(0.0, _scrubberHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to current logic if render box is not available
|
||||||
final dragAreaTop = widget.topPadding;
|
final dragAreaTop = widget.topPadding;
|
||||||
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
|
|
||||||
final dragAreaHeight = dragAreaBottom - dragAreaTop;
|
|
||||||
|
|
||||||
final relativePosition = details.globalPosition.dy - dragAreaTop;
|
final relativePosition = details.globalPosition.dy - dragAreaTop;
|
||||||
|
return relativePosition.clamp(0.0, _scrubberHeight);
|
||||||
// Make sure the position stays within the scrubber's bounds
|
|
||||||
return relativePosition.clamp(0.0, dragAreaHeight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the segment closest to the given position
|
/// Find the segment closest to the given position
|
||||||
@@ -294,12 +338,18 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDragEnd(DragEndDetails _) {
|
void _onDragEnd(DragEndDetails _) {
|
||||||
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
|
||||||
_labelAnimationController.reverse();
|
_labelAnimationController.reverse();
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = false;
|
_isDragging = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
||||||
|
|
||||||
|
// Reset scrubber tracking when drag ends
|
||||||
|
_currentScrubberDate = null;
|
||||||
|
_scrubberDebouncer?.dispose();
|
||||||
|
_scrubberDebouncer = null;
|
||||||
|
|
||||||
_resetThumbTimer();
|
_resetThumbTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,6 @@ class TimelineState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TimelineStateNotifier extends Notifier<TimelineState> {
|
class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||||
TimelineStateNotifier();
|
|
||||||
|
|
||||||
void setScrubbing(bool isScrubbing) {
|
void setScrubbing(bool isScrubbing) {
|
||||||
state = state.copyWith(isScrubbing: isScrubbing);
|
state = state.copyWith(isScrubbing: isScrubbing);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
|
|||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||||
@@ -38,6 +39,7 @@ class Timeline extends StatelessWidget {
|
|||||||
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
|
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
|
||||||
this.groupBy,
|
this.groupBy,
|
||||||
this.withScrubber = true,
|
this.withScrubber = true,
|
||||||
|
this.snapToMonth = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
@@ -48,11 +50,13 @@ class Timeline extends StatelessWidget {
|
|||||||
final bool withStack;
|
final bool withStack;
|
||||||
final GroupAssetsBy? groupBy;
|
final GroupAssetsBy? groupBy;
|
||||||
final bool withScrubber;
|
final bool withScrubber;
|
||||||
|
final bool snapToMonth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
|
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (_, constraints) => ProviderScope(
|
builder: (_, constraints) => ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
@@ -73,6 +77,7 @@ class Timeline extends StatelessWidget {
|
|||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
bottomSheet: bottomSheet,
|
bottomSheet: bottomSheet,
|
||||||
withScrubber: withScrubber,
|
withScrubber: withScrubber,
|
||||||
|
snapToMonth: snapToMonth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -87,6 +92,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
|||||||
this.appBar,
|
this.appBar,
|
||||||
this.bottomSheet,
|
this.bottomSheet,
|
||||||
this.withScrubber = true,
|
this.withScrubber = true,
|
||||||
|
this.snapToMonth = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
@@ -94,6 +100,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
|||||||
final Widget? appBar;
|
final Widget? appBar;
|
||||||
final Widget? bottomSheet;
|
final Widget? bottomSheet;
|
||||||
final bool withScrubber;
|
final bool withScrubber;
|
||||||
|
final bool snapToMonth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _SliverTimelineState();
|
ConsumerState createState() => _SliverTimelineState();
|
||||||
@@ -129,7 +136,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
void _onEvent(Event event) {
|
void _onEvent(Event event) {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case ScrollToTopEvent():
|
case ScrollToTopEvent():
|
||||||
_scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
|
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
||||||
|
_scrollController
|
||||||
|
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
|
||||||
|
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
|
||||||
|
|
||||||
case ScrollToDateEvent scrollToDateEvent:
|
case ScrollToDateEvent scrollToDateEvent:
|
||||||
_scrollToDate(scrollToDateEvent.date);
|
_scrollToDate(scrollToDateEvent.date);
|
||||||
case TimelineReloadEvent():
|
case TimelineReloadEvent():
|
||||||
@@ -305,11 +316,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
final Widget timeline;
|
final Widget timeline;
|
||||||
if (widget.withScrubber) {
|
if (widget.withScrubber) {
|
||||||
timeline = Scrubber(
|
timeline = Scrubber(
|
||||||
|
snapToMonth: widget.snapToMonth,
|
||||||
layoutSegments: segments,
|
layoutSegments: segments,
|
||||||
timelineHeight: maxHeight,
|
timelineHeight: maxHeight,
|
||||||
topPadding: topPadding,
|
topPadding: topPadding,
|
||||||
bottomPadding: bottomPadding,
|
bottomPadding: bottomPadding,
|
||||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||||
|
hasAppBar: widget.appBar != null,
|
||||||
child: grid,
|
child: grid,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -356,7 +369,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
children: [
|
children: [
|
||||||
timeline,
|
timeline,
|
||||||
if (!isSelectionMode && isMultiSelectEnabled) ...[
|
if (!isSelectionMode && isMultiSelectEnabled) ...[
|
||||||
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
|
Positioned(
|
||||||
|
top: MediaQuery.paddingOf(context).top,
|
||||||
|
left: 25,
|
||||||
|
child: const SizedBox(
|
||||||
|
height: kToolbarHeight,
|
||||||
|
child: Center(child: _MultiSelectStatusButton()),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (widget.bottomSheet != null) widget.bottomSheet!,
|
if (widget.bottomSheet != null) widget.bottomSheet!,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
|
|||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
|
|
||||||
class EnqueueStatus {
|
class EnqueueStatus {
|
||||||
final int enqueueCount;
|
final int enqueueCount;
|
||||||
@@ -234,6 +234,12 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
|||||||
|
|
||||||
switch (update.status) {
|
switch (update.status) {
|
||||||
case TaskStatus.complete:
|
case TaskStatus.complete:
|
||||||
|
if (update.task.group == kBackupGroup) {
|
||||||
|
if (update.responseStatusCode == 201) {
|
||||||
|
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove the completed task from the upload items
|
// Remove the completed task from the upload items
|
||||||
if (state.uploadItems.containsKey(taskId)) {
|
if (state.uploadItems.containsKey(taskId)) {
|
||||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||||
|
|||||||
@@ -358,7 +358,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
|
|
||||||
Future<ActionResult> downloadAll(ActionSource source) async {
|
Future<ActionResult> downloadAll(ActionSource source) async {
|
||||||
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
|
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final didEnqueue = await _service.downloadAll(assets);
|
final didEnqueue = await _service.downloadAll(assets);
|
||||||
final enqueueCount = didEnqueue.where((e) => e).length;
|
final enqueueCount = didEnqueue.where((e) => e).length;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
|
||||||
|
|
||||||
final syncStreamServiceProvider = Provider(
|
final syncStreamServiceProvider = Provider(
|
||||||
(ref) => SyncStreamService(
|
(ref) => SyncStreamService(
|
||||||
@@ -35,7 +34,6 @@ final hashServiceProvider = Provider(
|
|||||||
(ref) => HashService(
|
(ref) => HashService(
|
||||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
storageRepository: ref.watch(storageRepositoryProvider),
|
|
||||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -90,7 +90,11 @@ class DownloadRepository {
|
|||||||
final isVideo = asset.isVideo;
|
final isVideo = asset.isVideo;
|
||||||
final url = getOriginalUrlForRemoteId(id);
|
final url = getOriginalUrlForRemoteId(id);
|
||||||
|
|
||||||
if (Platform.isAndroid || livePhotoVideoId == null || isVideo) {
|
// on iOS it cannot link the image, check if the filename has .MP extension
|
||||||
|
// to avoid downloading the video part
|
||||||
|
final isAndroidMotionPhoto = asset.name.contains(".MP");
|
||||||
|
|
||||||
|
if (Platform.isAndroid || livePhotoVideoId == null || isVideo || isAndroidMotionPhoto) {
|
||||||
tasks[taskIndex++] = DownloadTask(
|
tasks[taskIndex++] = DownloadTask(
|
||||||
taskId: id,
|
taskId: id,
|
||||||
url: url,
|
url: url,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
|||||||
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
|
||||||
@@ -345,6 +346,7 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
|
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
// required to handle all deeplinks in deep_link.service.dart
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
|||||||
@@ -688,6 +688,22 @@ class CropImageRouteArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DownloadInfoPage]
|
||||||
|
class DownloadInfoRoute extends PageRouteInfo<void> {
|
||||||
|
const DownloadInfoRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(DownloadInfoRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'DownloadInfoRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const DownloadInfoPage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [DriftActivitiesPage]
|
/// [DriftActivitiesPage]
|
||||||
class DriftActivitiesRoute extends PageRouteInfo<void> {
|
class DriftActivitiesRoute extends PageRouteInfo<void> {
|
||||||
|
|||||||
@@ -201,14 +201,11 @@ class ActionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
||||||
int removedCount = 0;
|
|
||||||
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
||||||
|
|
||||||
if (result.removed.isNotEmpty) {
|
if (result.removed.isNotEmpty) {
|
||||||
removedCount = await _remoteAlbumRepository.removeAssets(albumId, result.removed);
|
await _remoteAlbumRepository.removeAssets(albumId, result.removed);
|
||||||
}
|
}
|
||||||
|
return result.removed.length;
|
||||||
return removedCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> updateDescription(String assetId, String description) async {
|
Future<bool> updateDescription(String assetId, String description) async {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ enum AppSettingsEnum<T> {
|
|||||||
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
||||||
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
||||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
||||||
|
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||||
|
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
|
||||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||||
|
|
||||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ class HashService {
|
|||||||
required IsarDeviceAssetRepository deviceAssetRepository,
|
required IsarDeviceAssetRepository deviceAssetRepository,
|
||||||
required BackgroundService backgroundService,
|
required BackgroundService backgroundService,
|
||||||
this.batchSizeLimit = kBatchHashSizeLimit,
|
this.batchSizeLimit = kBatchHashSizeLimit,
|
||||||
this.batchFileLimit = kBatchHashFileLimit,
|
int? batchFileLimit,
|
||||||
}) : _deviceAssetRepository = deviceAssetRepository,
|
}) : _deviceAssetRepository = deviceAssetRepository,
|
||||||
_backgroundService = backgroundService;
|
_backgroundService = backgroundService,
|
||||||
|
batchFileLimit = batchFileLimit ?? kBatchHashFileLimit;
|
||||||
|
|
||||||
final IsarDeviceAssetRepository _deviceAssetRepository;
|
final IsarDeviceAssetRepository _deviceAssetRepository;
|
||||||
final BackgroundService _backgroundService;
|
final BackgroundService _backgroundService;
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ enum ActionButtonType {
|
|||||||
context.asset.hasRemote,
|
context.asset.hasRemote,
|
||||||
ActionButtonType.deleteLocal =>
|
ActionButtonType.deleteLocal =>
|
||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
context.asset.storage == AssetState.local,
|
context.asset.hasLocal,
|
||||||
ActionButtonType.upload =>
|
ActionButtonType.upload =>
|
||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
context.asset.storage == AssetState.local,
|
context.asset.storage == AssetState.local,
|
||||||
|
|||||||
@@ -62,30 +62,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
|||||||
await Store.populateCache();
|
await Store.populateCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle migration only for this version
|
await handleBetaMigration(version, await _isNewInstallation(db, drift), SyncStreamRepository(drift));
|
||||||
// TODO: remove when old timeline is removed
|
|
||||||
final needBetaMigration = Store.tryGet(StoreKey.needBetaMigration);
|
|
||||||
if (version == 15 && needBetaMigration == null) {
|
|
||||||
// Check both databases directly instead of relying on cache
|
|
||||||
|
|
||||||
final isBeta = Store.tryGet(StoreKey.betaTimeline);
|
|
||||||
final isNewInstallation = await _isNewInstallation(db, drift);
|
|
||||||
|
|
||||||
// For new installations, no migration needed
|
|
||||||
// For existing installations, only migrate if beta timeline is not enabled (null or false)
|
|
||||||
if (isNewInstallation || isBeta == true) {
|
|
||||||
await Store.put(StoreKey.needBetaMigration, false);
|
|
||||||
await Store.put(StoreKey.betaTimeline, true);
|
|
||||||
} else {
|
|
||||||
await drift.reset();
|
|
||||||
await Store.put(StoreKey.needBetaMigration, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (version < 16) {
|
|
||||||
await SyncStreamRepository(drift).reset();
|
|
||||||
await Store.put(StoreKey.shouldResetSync, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetVersion >= 12) {
|
if (targetVersion >= 12) {
|
||||||
await Store.put(StoreKey.version, targetVersion);
|
await Store.put(StoreKey.version, targetVersion);
|
||||||
@@ -99,6 +76,37 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> handleBetaMigration(int version, bool isNewInstallation, SyncStreamRepository syncStreamRepository) async {
|
||||||
|
// Handle migration only for this version
|
||||||
|
// TODO: remove when old timeline is removed
|
||||||
|
final isBeta = Store.tryGet(StoreKey.betaTimeline);
|
||||||
|
final needBetaMigration = Store.tryGet(StoreKey.needBetaMigration);
|
||||||
|
if (version <= 15 && needBetaMigration == null) {
|
||||||
|
// For new installations, no migration needed
|
||||||
|
// For existing installations, only migrate if beta timeline is not enabled (null or false)
|
||||||
|
if (isNewInstallation || isBeta == true) {
|
||||||
|
await Store.put(StoreKey.needBetaMigration, false);
|
||||||
|
await Store.put(StoreKey.betaTimeline, true);
|
||||||
|
} else {
|
||||||
|
await Store.put(StoreKey.needBetaMigration, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version > 15) {
|
||||||
|
if (isBeta == null || isBeta) {
|
||||||
|
await Store.put(StoreKey.needBetaMigration, false);
|
||||||
|
await Store.put(StoreKey.betaTimeline, true);
|
||||||
|
} else {
|
||||||
|
await Store.put(StoreKey.needBetaMigration, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version < 16) {
|
||||||
|
await syncStreamRepository.reset();
|
||||||
|
await Store.put(StoreKey.shouldResetSync, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _isNewInstallation(Isar db, Drift drift) async {
|
Future<bool> _isNewInstallation(Isar db, Drift drift) async {
|
||||||
try {
|
try {
|
||||||
final isarUserCount = await db.users.count();
|
final isarUserCount = await db.users.count();
|
||||||
|
|||||||
@@ -8,8 +8,17 @@ class BackupInfoCard extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final String info;
|
final String info;
|
||||||
|
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
const BackupInfoCard({super.key, required this.title, required this.subtitle, required this.info, this.onTap});
|
final bool isLoading;
|
||||||
|
const BackupInfoCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.info,
|
||||||
|
this.onTap,
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -38,8 +47,36 @@ class BackupInfoCard extends StatelessWidget {
|
|||||||
trailing: Column(
|
trailing: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(info, style: context.textTheme.titleLarge),
|
Stack(
|
||||||
Text("backup_info_card_assets", style: context.textTheme.labelLarge).tr(),
|
children: [
|
||||||
|
Text(
|
||||||
|
info,
|
||||||
|
style: context.textTheme.titleLarge?.copyWith(
|
||||||
|
color: context.colorScheme.onSurface.withAlpha(isLoading ? 50 : 255),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isLoading)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: context.colorScheme.onSurface.withAlpha(150),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"backup_info_card_assets",
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(
|
||||||
|
color: context.colorScheme.onSurface.withAlpha(isLoading ? 50 : 255),
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -75,86 +75,79 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
|
|||||||
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isMultiSelectEnabled) {
|
return SliverAppBar(
|
||||||
return SliverToBoxAdapter(
|
expandedHeight: 400.0,
|
||||||
child: switch (_scrollProgress) {
|
floating: false,
|
||||||
< 0.8 => const SizedBox(height: 120),
|
pinned: true,
|
||||||
_ => const SizedBox(height: 452),
|
snap: false,
|
||||||
},
|
elevation: 0,
|
||||||
);
|
leading: isMultiSelectEnabled
|
||||||
} else {
|
? const SizedBox.shrink()
|
||||||
return SliverAppBar(
|
: IconButton(
|
||||||
expandedHeight: 400.0,
|
icon: Icon(
|
||||||
floating: false,
|
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
||||||
pinned: true,
|
color: actionIconColor,
|
||||||
snap: false,
|
shadows: actionIconShadows,
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
|
||||||
color: actionIconColor,
|
|
||||||
shadows: actionIconShadows,
|
|
||||||
),
|
|
||||||
onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
if (widget.onToggleAlbumOrder != null)
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
|
|
||||||
onPressed: widget.onToggleAlbumOrder,
|
|
||||||
),
|
|
||||||
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
|
|
||||||
onPressed: widget.onActivity,
|
|
||||||
),
|
|
||||||
if (widget.onShowOptions != null)
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
|
||||||
onPressed: widget.onShowOptions,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
title: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
|
||||||
final scrollProgress = _calculateScrollProgress(settings);
|
|
||||||
|
|
||||||
return AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: scrollProgress > 0.95
|
|
||||||
? Text(
|
|
||||||
currentAlbum.name,
|
|
||||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
flexibleSpace: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
|
||||||
final scrollProgress = _calculateScrollProgress(settings);
|
|
||||||
|
|
||||||
// Update scroll progress for the leading button
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted && _scrollProgress != scrollProgress) {
|
|
||||||
setState(() {
|
|
||||||
_scrollProgress = scrollProgress;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
|
||||||
background: _ExpandedBackground(
|
|
||||||
scrollProgress: scrollProgress,
|
|
||||||
icon: widget.icon,
|
|
||||||
onEditTitle: widget.onEditTitle,
|
|
||||||
),
|
),
|
||||||
);
|
onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])),
|
||||||
},
|
),
|
||||||
),
|
actions: [
|
||||||
);
|
if (widget.onToggleAlbumOrder != null)
|
||||||
}
|
IconButton(
|
||||||
|
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
|
||||||
|
onPressed: widget.onToggleAlbumOrder,
|
||||||
|
),
|
||||||
|
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
|
||||||
|
onPressed: widget.onActivity,
|
||||||
|
),
|
||||||
|
if (widget.onShowOptions != null)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
||||||
|
onPressed: widget.onShowOptions,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
title: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||||
|
final scrollProgress = _calculateScrollProgress(settings);
|
||||||
|
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: scrollProgress > 0.95
|
||||||
|
? Text(
|
||||||
|
currentAlbum.name,
|
||||||
|
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
flexibleSpace: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||||
|
final scrollProgress = _calculateScrollProgress(settings);
|
||||||
|
|
||||||
|
// Update scroll progress for the leading button
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _scrollProgress != scrollProgress) {
|
||||||
|
setState(() {
|
||||||
|
_scrollProgress = scrollProgress;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
background: _ExpandedBackground(
|
||||||
|
scrollProgress: scrollProgress,
|
||||||
|
icon: widget.icon,
|
||||||
|
onEditTitle: widget.onEditTitle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||||
@@ -18,12 +23,40 @@ class DriftBackupSettings extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return const SettingsSubPageScaffold(
|
return SettingsSubPageScaffold(
|
||||||
settings: [
|
settings: [
|
||||||
_UseWifiForUploadVideosButton(),
|
Padding(
|
||||||
_UseWifiForUploadPhotosButton(),
|
padding: const EdgeInsets.only(left: 16.0),
|
||||||
Divider(indent: 16, endIndent: 16),
|
child: Text(
|
||||||
_AlbumSyncActionButton(),
|
"network_requirements".t(context: context).toUpperCase(),
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const _UseWifiForUploadVideosButton(),
|
||||||
|
const _UseWifiForUploadPhotosButton(),
|
||||||
|
if (CurrentPlatform.isAndroid) ...[
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16.0),
|
||||||
|
child: Text(
|
||||||
|
"background_options".t(context: context).toUpperCase(),
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
|
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const _BackupOnlyWhenChargingButton(),
|
||||||
|
const _BackupDelaySlider(),
|
||||||
|
],
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16.0),
|
||||||
|
child: Text(
|
||||||
|
"backup_albums_sync".t(context: context).toUpperCase(),
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const _AlbumSyncActionButton(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -151,30 +184,59 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
class _SettingsSwitchTile extends ConsumerStatefulWidget {
|
||||||
const _UseWifiForUploadVideosButton();
|
final AppSettingsEnum<bool> appSettingsEnum;
|
||||||
|
final String titleKey;
|
||||||
|
final String subtitleKey;
|
||||||
|
final void Function(bool?)? onChanged;
|
||||||
|
|
||||||
|
const _SettingsSwitchTile({
|
||||||
|
required this.appSettingsEnum,
|
||||||
|
required this.titleKey,
|
||||||
|
required this.subtitleKey,
|
||||||
|
this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState createState() => _SettingsSwitchTileState();
|
||||||
final valueStream = Store.watch(StoreKey.useWifiForUploadVideos);
|
}
|
||||||
|
|
||||||
|
class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
|
||||||
|
late final Stream<bool?> valueStream;
|
||||||
|
late final StreamSubscription<bool?> subscription;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
valueStream = Store.watch(widget.appSettingsEnum.storeKey).asBroadcastStream();
|
||||||
|
subscription = valueStream.listen((value) {
|
||||||
|
widget.onChanged?.call(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
subscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
"videos".t(context: context),
|
widget.titleKey.t(context: context),
|
||||||
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
|
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
|
||||||
),
|
),
|
||||||
subtitle: Text("network_requirement_videos_upload".t(context: context), style: context.textTheme.labelLarge),
|
subtitle: Text(widget.subtitleKey.t(context: context), style: context.textTheme.labelLarge),
|
||||||
trailing: StreamBuilder(
|
trailing: StreamBuilder(
|
||||||
stream: valueStream,
|
stream: valueStream,
|
||||||
initialData: Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false,
|
initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final value = snapshot.data ?? false;
|
final value = snapshot.data ?? false;
|
||||||
return Switch(
|
return Switch(
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: (bool newValue) async {
|
onChanged: (bool newValue) async {
|
||||||
await ref
|
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue);
|
||||||
.read(appSettingsServiceProvider)
|
|
||||||
.setSetting(AppSettingsEnum.useCellularForUploadVideos, newValue);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -183,34 +245,135 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
||||||
|
const _UseWifiForUploadVideosButton();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return const _SettingsSwitchTile(
|
||||||
|
appSettingsEnum: AppSettingsEnum.useCellularForUploadVideos,
|
||||||
|
titleKey: "videos",
|
||||||
|
subtitleKey: "network_requirement_videos_upload",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _UseWifiForUploadPhotosButton extends ConsumerWidget {
|
class _UseWifiForUploadPhotosButton extends ConsumerWidget {
|
||||||
const _UseWifiForUploadPhotosButton();
|
const _UseWifiForUploadPhotosButton();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final valueStream = Store.watch(StoreKey.useWifiForUploadPhotos);
|
return const _SettingsSwitchTile(
|
||||||
|
appSettingsEnum: AppSettingsEnum.useCellularForUploadPhotos,
|
||||||
return ListTile(
|
titleKey: "photos",
|
||||||
title: Text(
|
subtitleKey: "network_requirement_photos_upload",
|
||||||
"photos".t(context: context),
|
);
|
||||||
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
|
}
|
||||||
),
|
}
|
||||||
subtitle: Text("network_requirement_photos_upload".t(context: context), style: context.textTheme.labelLarge),
|
|
||||||
trailing: StreamBuilder(
|
class _BackupOnlyWhenChargingButton extends ConsumerWidget {
|
||||||
stream: valueStream,
|
const _BackupOnlyWhenChargingButton();
|
||||||
initialData: Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false,
|
|
||||||
builder: (context, snapshot) {
|
@override
|
||||||
final value = snapshot.data ?? false;
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Switch(
|
return _SettingsSwitchTile(
|
||||||
value: value,
|
appSettingsEnum: AppSettingsEnum.backupRequireCharging,
|
||||||
onChanged: (bool newValue) async {
|
titleKey: "charging",
|
||||||
await ref
|
subtitleKey: "charging_requirement_mobile_backup",
|
||||||
.read(appSettingsServiceProvider)
|
onChanged: (value) {
|
||||||
.setSetting(AppSettingsEnum.useCellularForUploadPhotos, newValue);
|
ref.read(backgroundWorkerFgServiceProvider).configure(requireCharging: value ?? false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
),
|
}
|
||||||
|
|
||||||
|
class _BackupDelaySlider extends ConsumerStatefulWidget {
|
||||||
|
const _BackupDelaySlider();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_BackupDelaySlider> createState() => _BackupDelaySliderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
|
||||||
|
late final Stream<int?> valueStream;
|
||||||
|
late final StreamSubscription<int?> subscription;
|
||||||
|
late int currentValue;
|
||||||
|
|
||||||
|
static int backupDelayToSliderValue(int ms) => switch (ms) {
|
||||||
|
5 => 0,
|
||||||
|
30 => 1,
|
||||||
|
120 => 2,
|
||||||
|
_ => 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
static int backupDelayToSeconds(int v) => switch (v) {
|
||||||
|
0 => 5,
|
||||||
|
1 => 30,
|
||||||
|
2 => 120,
|
||||||
|
_ => 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
static String formatBackupDelaySliderValue(int v) => switch (v) {
|
||||||
|
0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
|
||||||
|
1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
|
||||||
|
2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
|
||||||
|
_ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initialValue =
|
||||||
|
Store.tryGet(AppSettingsEnum.backupTriggerDelay.storeKey) ?? AppSettingsEnum.backupTriggerDelay.defaultValue;
|
||||||
|
currentValue = backupDelayToSliderValue(initialValue);
|
||||||
|
|
||||||
|
valueStream = Store.watch(AppSettingsEnum.backupTriggerDelay.storeKey).asBroadcastStream();
|
||||||
|
subscription = valueStream.listen((value) {
|
||||||
|
if (mounted && value != null) {
|
||||||
|
setState(() {
|
||||||
|
currentValue = backupDelayToSliderValue(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
subscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16.0, top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
'backup_controller_page_background_delay'.tr(
|
||||||
|
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
|
||||||
|
),
|
||||||
|
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: currentValue.toDouble(),
|
||||||
|
onChanged: (double v) {
|
||||||
|
setState(() {
|
||||||
|
currentValue = v.toInt();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onChangeEnd: (double v) async {
|
||||||
|
final milliseconds = backupDelayToSeconds(v.toInt());
|
||||||
|
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.backupTriggerDelay, milliseconds);
|
||||||
|
},
|
||||||
|
max: 3.0,
|
||||||
|
min: 0.0,
|
||||||
|
divisions: 3,
|
||||||
|
label: formatBackupDelaySliderValue(currentValue),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,19 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
dartPackageName: 'immich_mobile',
|
dartPackageName: 'immich_mobile',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
class BackgroundWorkerSettings {
|
||||||
|
final bool requiresCharging;
|
||||||
|
final int minimumDelaySeconds;
|
||||||
|
|
||||||
|
const BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
|
||||||
|
}
|
||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class BackgroundWorkerFgHostApi {
|
abstract class BackgroundWorkerFgHostApi {
|
||||||
void enable();
|
void enable();
|
||||||
|
|
||||||
|
void configure(BackgroundWorkerSettings settings);
|
||||||
|
|
||||||
void disable();
|
void disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ class SyncDelta {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class HashResult {
|
||||||
|
final String assetId;
|
||||||
|
final String? error;
|
||||||
|
final String? hash;
|
||||||
|
|
||||||
|
const HashResult({required this.assetId, this.error, this.hash});
|
||||||
|
}
|
||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class NativeSyncApi {
|
abstract class NativeSyncApi {
|
||||||
bool shouldFullSync();
|
bool shouldFullSync();
|
||||||
@@ -94,6 +102,9 @@ abstract class NativeSyncApi {
|
|||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<Uint8List?> hashPaths(List<String> paths);
|
List<HashResult> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false});
|
||||||
|
|
||||||
|
void cancelHashing();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2171,4 +2171,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.0 <4.0.0"
|
dart: ">=3.8.0 <4.0.0"
|
||||||
flutter: ">=3.35.3"
|
flutter: ">=3.35.4"
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ version: 1.142.1+3015
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.8.0 <4.0.0'
|
sdk: '>=3.8.0 <4.0.0'
|
||||||
flutter: 3.35.3
|
flutter: 3.35.4
|
||||||
|
|
||||||
isar_version: &isar_version 3.1.8
|
isar_version: &isar_version 3.1.8
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
import '../../fixtures/album.stub.dart';
|
import '../../fixtures/album.stub.dart';
|
||||||
@@ -13,192 +9,137 @@ import '../../fixtures/asset.stub.dart';
|
|||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
import '../service.mock.dart';
|
import '../service.mock.dart';
|
||||||
|
|
||||||
class MockFile extends Mock implements File {}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late HashService sut;
|
late HashService sut;
|
||||||
late MockLocalAlbumRepository mockAlbumRepo;
|
late MockLocalAlbumRepository mockAlbumRepo;
|
||||||
late MockLocalAssetRepository mockAssetRepo;
|
late MockLocalAssetRepository mockAssetRepo;
|
||||||
late MockStorageRepository mockStorageRepo;
|
|
||||||
late MockNativeSyncApi mockNativeApi;
|
late MockNativeSyncApi mockNativeApi;
|
||||||
final sortBy = {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum};
|
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockAlbumRepo = MockLocalAlbumRepository();
|
mockAlbumRepo = MockLocalAlbumRepository();
|
||||||
mockAssetRepo = MockLocalAssetRepository();
|
mockAssetRepo = MockLocalAssetRepository();
|
||||||
mockStorageRepo = MockStorageRepository();
|
|
||||||
mockNativeApi = MockNativeSyncApi();
|
mockNativeApi = MockNativeSyncApi();
|
||||||
|
|
||||||
sut = HashService(
|
sut = HashService(
|
||||||
localAlbumRepository: mockAlbumRepo,
|
localAlbumRepository: mockAlbumRepo,
|
||||||
localAssetRepository: mockAssetRepo,
|
localAssetRepository: mockAssetRepo,
|
||||||
storageRepository: mockStorageRepo,
|
|
||||||
nativeSyncApi: mockNativeApi,
|
nativeSyncApi: mockNativeApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
registerFallbackValue(LocalAlbumStub.recent);
|
registerFallbackValue(LocalAlbumStub.recent);
|
||||||
registerFallbackValue(LocalAssetStub.image1);
|
registerFallbackValue(LocalAssetStub.image1);
|
||||||
|
registerFallbackValue(<String, String>{});
|
||||||
|
|
||||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
when(() => mockStorageRepo.clearCache()).thenAnswer((_) async => {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('HashService hashAssets', () {
|
group('HashService hashAssets', () {
|
||||||
test('skips albums with no assets to hash', () async {
|
test('skips albums with no assets to hash', () async {
|
||||||
when(
|
when(
|
||||||
() => mockAlbumRepo.getAll(sortBy: sortBy),
|
() => mockAlbumRepo.getBackupAlbums(),
|
||||||
).thenAnswer((_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)]);
|
).thenAnswer((_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)).thenAnswer((_) async => []);
|
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)).thenAnswer((_) async => []);
|
||||||
|
|
||||||
await sut.hashAssets();
|
await sut.hashAssets();
|
||||||
|
|
||||||
verifyNever(() => mockStorageRepo.getFileForAsset(any()));
|
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||||
verifyNever(() => mockNativeApi.hashPaths(any()));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('HashService _hashAssets', () {
|
group('HashService _hashAssets', () {
|
||||||
test('skips assets without files', () async {
|
test('skips empty batches', () async {
|
||||||
final album = LocalAlbumStub.recent;
|
final album = LocalAlbumStub.recent;
|
||||||
final asset = LocalAssetStub.image1;
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => null);
|
|
||||||
|
|
||||||
await sut.hashAssets();
|
await sut.hashAssets();
|
||||||
|
|
||||||
verifyNever(() => mockNativeApi.hashPaths(any()));
|
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('processes assets when available', () async {
|
test('processes assets when available', () async {
|
||||||
final album = LocalAlbumStub.recent;
|
final album = LocalAlbumStub.recent;
|
||||||
final asset = LocalAssetStub.image1;
|
final asset = LocalAssetStub.image1;
|
||||||
final mockFile = MockFile();
|
|
||||||
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
|
||||||
|
|
||||||
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||||
when(() => mockFile.path).thenReturn('image-path');
|
|
||||||
|
|
||||||
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
|
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
when(
|
||||||
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [hash]);
|
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||||
|
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'test-hash')]);
|
||||||
|
|
||||||
await sut.hashAssets();
|
await sut.hashAssets();
|
||||||
|
|
||||||
verify(() => mockNativeApi.hashPaths(['image-path'])).called(1);
|
verify(() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
||||||
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>;
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||||
expect(captured.length, 1);
|
expect(captured.length, 1);
|
||||||
expect(captured[0].checksum, base64.encode(hash));
|
expect(captured[asset.id], 'test-hash');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles failed hashes', () async {
|
test('handles failed hashes', () async {
|
||||||
final album = LocalAlbumStub.recent;
|
final album = LocalAlbumStub.recent;
|
||||||
final asset = LocalAssetStub.image1;
|
final asset = LocalAssetStub.image1;
|
||||||
final mockFile = MockFile();
|
|
||||||
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
|
||||||
when(() => mockFile.path).thenReturn('image-path');
|
|
||||||
|
|
||||||
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
when(
|
||||||
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [null]);
|
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
|
||||||
|
|
||||||
await sut.hashAssets();
|
await sut.hashAssets();
|
||||||
|
|
||||||
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>;
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||||
expect(captured.length, 0);
|
expect(captured.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles invalid hash length', () async {
|
test('handles null hash results', () async {
|
||||||
final album = LocalAlbumStub.recent;
|
final album = LocalAlbumStub.recent;
|
||||||
final asset = LocalAssetStub.image1;
|
final asset = LocalAssetStub.image1;
|
||||||
final mockFile = MockFile();
|
|
||||||
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
|
||||||
when(() => mockFile.path).thenReturn('image-path');
|
|
||||||
|
|
||||||
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
when(
|
||||||
|
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||||
final invalidHash = Uint8List.fromList([1, 2, 3]);
|
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
|
||||||
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [invalidHash]);
|
|
||||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
|
||||||
|
|
||||||
await sut.hashAssets();
|
await sut.hashAssets();
|
||||||
|
|
||||||
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>;
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||||
expect(captured.length, 0);
|
expect(captured.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('batches by file count limit', () async {
|
|
||||||
final sut = HashService(
|
|
||||||
localAlbumRepository: mockAlbumRepo,
|
|
||||||
localAssetRepository: mockAssetRepo,
|
|
||||||
storageRepository: mockStorageRepo,
|
|
||||||
nativeSyncApi: mockNativeApi,
|
|
||||||
batchFileLimit: 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
final album = LocalAlbumStub.recent;
|
|
||||||
final asset1 = LocalAssetStub.image1;
|
|
||||||
final asset2 = LocalAssetStub.image2;
|
|
||||||
final mockFile1 = MockFile();
|
|
||||||
final mockFile2 = MockFile();
|
|
||||||
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
|
||||||
when(() => mockFile1.path).thenReturn('path-1');
|
|
||||||
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
|
||||||
when(() => mockFile2.path).thenReturn('path-2');
|
|
||||||
|
|
||||||
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
|
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset1.id)).thenAnswer((_) async => mockFile1);
|
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset2.id)).thenAnswer((_) async => mockFile2);
|
|
||||||
|
|
||||||
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
|
||||||
when(() => mockNativeApi.hashPaths(any())).thenAnswer((_) async => [hash]);
|
|
||||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
|
||||||
|
|
||||||
await sut.hashAssets();
|
|
||||||
|
|
||||||
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
|
|
||||||
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
|
|
||||||
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('batches by size limit', () async {
|
test('batches by size limit', () async {
|
||||||
|
const batchSize = 2;
|
||||||
final sut = HashService(
|
final sut = HashService(
|
||||||
localAlbumRepository: mockAlbumRepo,
|
localAlbumRepository: mockAlbumRepo,
|
||||||
localAssetRepository: mockAssetRepo,
|
localAssetRepository: mockAssetRepo,
|
||||||
storageRepository: mockStorageRepo,
|
|
||||||
nativeSyncApi: mockNativeApi,
|
nativeSyncApi: mockNativeApi,
|
||||||
batchSizeLimit: 80,
|
batchSize: batchSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
final album = LocalAlbumStub.recent;
|
final album = LocalAlbumStub.recent;
|
||||||
final asset1 = LocalAssetStub.image1;
|
final asset1 = LocalAssetStub.image1;
|
||||||
final asset2 = LocalAssetStub.image2;
|
final asset2 = LocalAssetStub.image2;
|
||||||
final mockFile1 = MockFile();
|
final asset3 = LocalAssetStub.image1.copyWith(id: 'image3', name: 'image3.jpg');
|
||||||
final mockFile2 = MockFile();
|
|
||||||
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
|
||||||
when(() => mockFile1.path).thenReturn('path-1');
|
|
||||||
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
|
||||||
when(() => mockFile2.path).thenReturn('path-2');
|
|
||||||
|
|
||||||
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
|
final capturedCalls = <List<String>>[];
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset1.id)).thenAnswer((_) async => mockFile1);
|
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset2.id)).thenAnswer((_) async => mockFile2);
|
|
||||||
|
|
||||||
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
|
||||||
when(() => mockNativeApi.hashPaths(any())).thenAnswer((_) async => [hash]);
|
|
||||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
||||||
|
when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||||
|
invocation,
|
||||||
|
) async {
|
||||||
|
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||||
|
capturedCalls.add(List<String>.from(assetIds));
|
||||||
|
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||||
|
});
|
||||||
|
|
||||||
await sut.hashAssets();
|
await sut.hashAssets();
|
||||||
|
|
||||||
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
|
expect(capturedCalls.length, 2, reason: 'Should make exactly 2 calls to hashAssets');
|
||||||
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
|
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
|
||||||
|
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
|
||||||
|
|
||||||
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,27 +147,43 @@ void main() {
|
|||||||
final album = LocalAlbumStub.recent;
|
final album = LocalAlbumStub.recent;
|
||||||
final asset1 = LocalAssetStub.image1;
|
final asset1 = LocalAssetStub.image1;
|
||||||
final asset2 = LocalAssetStub.image2;
|
final asset2 = LocalAssetStub.image2;
|
||||||
final mockFile1 = MockFile();
|
|
||||||
final mockFile2 = MockFile();
|
|
||||||
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
|
||||||
when(() => mockFile1.path).thenReturn('path-1');
|
|
||||||
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
|
||||||
when(() => mockFile2.path).thenReturn('path-2');
|
|
||||||
|
|
||||||
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset1.id)).thenAnswer((_) async => mockFile1);
|
when(() => mockNativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset2.id)).thenAnswer((_) async => mockFile2);
|
(_) async => [
|
||||||
|
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
|
||||||
final validHash = Uint8List.fromList(List.generate(20, (i) => i));
|
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
|
||||||
when(() => mockNativeApi.hashPaths(['path-1', 'path-2'])).thenAnswer((_) async => [validHash, null]);
|
],
|
||||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
);
|
||||||
|
|
||||||
await sut.hashAssets();
|
await sut.hashAssets();
|
||||||
|
|
||||||
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>;
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||||
expect(captured.length, 1);
|
expect(captured.length, 1);
|
||||||
expect(captured.first.id, asset1.id);
|
expect(captured[asset1.id], 'asset1-hash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses allowNetworkAccess based on album backup selection', () async {
|
||||||
|
final selectedAlbum = LocalAlbumStub.recent.copyWith(backupSelection: BackupSelection.selected);
|
||||||
|
final nonSelectedAlbum = LocalAlbumStub.recent.copyWith(id: 'album2', backupSelection: BackupSelection.excluded);
|
||||||
|
final asset1 = LocalAssetStub.image1;
|
||||||
|
final asset2 = LocalAssetStub.image2;
|
||||||
|
|
||||||
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
||||||
|
when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||||
|
invocation,
|
||||||
|
) async {
|
||||||
|
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||||
|
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
verify(() => mockNativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
||||||
|
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -13,6 +13,7 @@ import 'schema_v7.dart' as v7;
|
|||||||
import 'schema_v8.dart' as v8;
|
import 'schema_v8.dart' as v8;
|
||||||
import 'schema_v9.dart' as v9;
|
import 'schema_v9.dart' as v9;
|
||||||
import 'schema_v10.dart' as v10;
|
import 'schema_v10.dart' as v10;
|
||||||
|
import 'schema_v11.dart' as v11;
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
@@ -38,10 +39,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
return v9.DatabaseAtV9(db);
|
return v9.DatabaseAtV9(db);
|
||||||
case 10:
|
case 10:
|
||||||
return v10.DatabaseAtV10(db);
|
return v10.DatabaseAtV10(db);
|
||||||
|
case 11:
|
||||||
|
return v11.DatabaseAtV11(db);
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||||
}
|
}
|
||||||
|
|||||||
+7198
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
|||||||
|
import 'package:drift/drift.dart' hide isNull;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
|
import 'package:immich_mobile/utils/migration.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../infrastructure/repository.mock.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Drift db;
|
||||||
|
late SyncStreamRepository mockSyncStreamRepository;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||||
|
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||||
|
mockSyncStreamRepository = MockSyncStreamRepository();
|
||||||
|
when(() => mockSyncStreamRepository.reset()).thenAnswer((_) async => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await Store.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('handleBetaMigration Tests', () {
|
||||||
|
group("version < 15", () {
|
||||||
|
test('already on new timeline', () async {
|
||||||
|
await Store.put(StoreKey.betaTimeline, true);
|
||||||
|
|
||||||
|
await handleBetaMigration(14, false, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.betaTimeline), true);
|
||||||
|
expect(Store.tryGet(StoreKey.needBetaMigration), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('already on old timeline', () async {
|
||||||
|
await Store.put(StoreKey.betaTimeline, false);
|
||||||
|
|
||||||
|
await handleBetaMigration(14, false, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.needBetaMigration), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fresh install', () async {
|
||||||
|
await Store.delete(StoreKey.betaTimeline);
|
||||||
|
await handleBetaMigration(14, true, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.betaTimeline), true);
|
||||||
|
expect(Store.tryGet(StoreKey.needBetaMigration), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("version == 15", () {
|
||||||
|
test('already on new timeline', () async {
|
||||||
|
await Store.put(StoreKey.betaTimeline, true);
|
||||||
|
|
||||||
|
await handleBetaMigration(15, false, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.betaTimeline), true);
|
||||||
|
expect(Store.tryGet(StoreKey.needBetaMigration), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('already on old timeline', () async {
|
||||||
|
await Store.put(StoreKey.betaTimeline, false);
|
||||||
|
|
||||||
|
await handleBetaMigration(15, false, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.needBetaMigration), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fresh install', () async {
|
||||||
|
await Store.delete(StoreKey.betaTimeline);
|
||||||
|
await handleBetaMigration(15, true, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.betaTimeline), true);
|
||||||
|
expect(Store.tryGet(StoreKey.needBetaMigration), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("version > 15", () {
|
||||||
|
test('already on new timeline', () async {
|
||||||
|
await Store.put(StoreKey.betaTimeline, true);
|
||||||
|
|
||||||
|
await handleBetaMigration(16, false, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.betaTimeline), true);
|
||||||
|
expect(Store.tryGet(StoreKey.needBetaMigration), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('already on old timeline', () async {
|
||||||
|
await Store.put(StoreKey.betaTimeline, false);
|
||||||
|
|
||||||
|
await handleBetaMigration(16, false, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.betaTimeline), false);
|
||||||
|
expect(Store.tryGet(StoreKey.needBetaMigration), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fresh install', () async {
|
||||||
|
await Store.delete(StoreKey.betaTimeline);
|
||||||
|
await handleBetaMigration(16, true, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.betaTimeline), true);
|
||||||
|
expect(Store.tryGet(StoreKey.needBetaMigration), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('sync reset tests', () {
|
||||||
|
test('version < 16', () async {
|
||||||
|
await Store.put(StoreKey.shouldResetSync, false);
|
||||||
|
|
||||||
|
await handleBetaMigration(15, false, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.shouldResetSync), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('version >= 16', () async {
|
||||||
|
await Store.put(StoreKey.shouldResetSync, false);
|
||||||
|
|
||||||
|
await handleBetaMigration(16, false, mockSyncStreamRepository);
|
||||||
|
|
||||||
|
expect(Store.tryGet(StoreKey.shouldResetSync), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -502,6 +502,21 @@ void main() {
|
|||||||
|
|
||||||
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
|
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should show when asset is merged', () {
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: mergedAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
advancedTroubleshooting: false,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('upload button', () {
|
group('upload button', () {
|
||||||
|
|||||||
Generated
+17
-10
@@ -684,8 +684,8 @@ importers:
|
|||||||
specifier: file:../open-api/typescript-sdk
|
specifier: file:../open-api/typescript-sdk
|
||||||
version: link:../open-api/typescript-sdk
|
version: link:../open-api/typescript-sdk
|
||||||
'@immich/ui':
|
'@immich/ui':
|
||||||
specifier: ^0.28.1
|
specifier: ^0.29.0
|
||||||
version: 0.28.1(@internationalized/date@3.8.2)(svelte@5.35.5)
|
version: 0.29.0(@internationalized/date@3.8.2)(svelte@5.35.5)
|
||||||
'@mapbox/mapbox-gl-rtl-text':
|
'@mapbox/mapbox-gl-rtl-text':
|
||||||
specifier: 0.2.3
|
specifier: 0.2.3
|
||||||
version: 0.2.3(mapbox-gl@1.13.3)
|
version: 0.2.3(mapbox-gl@1.13.3)
|
||||||
@@ -2717,8 +2717,8 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@immich/ui@0.28.1':
|
'@immich/ui@0.29.0':
|
||||||
resolution: {integrity: sha512-FAQBPsbPaLtTYsEH+/wQxPbUI19ZPuOEScSHgSqteI601qVZNQcfU7YuinKqK94iva2RNhvUgrNGHweIBentZg==}
|
resolution: {integrity: sha512-An9cf1L4nMO6+C1Tkktd+qjGmZvyGz/Un33cGsKQa2I7IdZHd67KbbC2v3wN3bQMiTjxtFJ8YR9EONohJ8jDtQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
@@ -4570,6 +4570,9 @@ packages:
|
|||||||
'@types/node@22.18.4':
|
'@types/node@22.18.4':
|
||||||
resolution: {integrity: sha512-UJdblFqXymSBhmZf96BnbisoFIr8ooiiBRMolQgg77Ea+VM37jXw76C2LQr9n8wm9+i/OvlUlW6xSvqwzwqznw==}
|
resolution: {integrity: sha512-UJdblFqXymSBhmZf96BnbisoFIr8ooiiBRMolQgg77Ea+VM37jXw76C2LQr9n8wm9+i/OvlUlW6xSvqwzwqznw==}
|
||||||
|
|
||||||
|
'@types/node@22.18.5':
|
||||||
|
resolution: {integrity: sha512-g9BpPfJvxYBXUWI9bV37j6d6LTMNQ88hPwdWWUeYZnMhlo66FIg9gCc1/DZb15QylJSKwOZjwrckvOTWpOiChg==}
|
||||||
|
|
||||||
'@types/node@24.3.0':
|
'@types/node@24.3.0':
|
||||||
resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==}
|
resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==}
|
||||||
|
|
||||||
@@ -5225,8 +5228,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
bits-ui@2.9.8:
|
bits-ui@2.9.9:
|
||||||
resolution: {integrity: sha512-oVAqdhLSuGIgEiT0yu3ShSI7AxncCxX26Gv6Lul94BuKHV2uzHoKfIodtnMQSq+udJ54svuCIRqA58whsv7vaA==}
|
resolution: {integrity: sha512-U8qsCQ/5rwXAzUBn8lCLFUeHBoXsA54Lb4uFuurXu6VEszVXLCedZRnhHOQsTc1I+2Js5l4iLiBtPUG7WjNbOA==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@internationalized/date': ^3.8.1
|
'@internationalized/date': ^3.8.1
|
||||||
@@ -14395,10 +14398,10 @@ snapshots:
|
|||||||
'@img/sharp-win32-x64@0.34.3':
|
'@img/sharp-win32-x64@0.34.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@immich/ui@0.28.1(@internationalized/date@3.8.2)(svelte@5.35.5)':
|
'@immich/ui@0.29.0(@internationalized/date@3.8.2)(svelte@5.35.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mdi/js': 7.4.47
|
'@mdi/js': 7.4.47
|
||||||
bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.35.5)
|
bits-ui: 2.9.9(@internationalized/date@3.8.2)(svelte@5.35.5)
|
||||||
simple-icons: 15.15.0
|
simple-icons: 15.15.0
|
||||||
svelte: 5.35.5
|
svelte: 5.35.5
|
||||||
tailwind-merge: 3.3.1
|
tailwind-merge: 3.3.1
|
||||||
@@ -16524,6 +16527,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/node@22.18.5':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/node@24.3.0':
|
'@types/node@24.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.10.0
|
undici-types: 7.10.0
|
||||||
@@ -16670,7 +16677,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/through@0.0.33':
|
'@types/through@0.0.33':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.18.4
|
'@types/node': 22.18.5
|
||||||
|
|
||||||
'@types/ua-parser-js@0.7.39': {}
|
'@types/ua-parser-js@0.7.39': {}
|
||||||
|
|
||||||
@@ -17339,7 +17346,7 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
bits-ui@2.9.8(@internationalized/date@3.8.2)(svelte@5.35.5):
|
bits-ui@2.9.9(@internationalized/date@3.8.2)(svelte@5.35.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/core': 1.7.3
|
'@floating-ui/core': 1.7.3
|
||||||
'@floating-ui/dom': 1.7.4
|
'@floating-ui/dom': 1.7.4
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
|
|||||||
# Flutter SDK
|
# Flutter SDK
|
||||||
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
|
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
|
||||||
ENV FLUTTER_CHANNEL="stable"
|
ENV FLUTTER_CHANNEL="stable"
|
||||||
ENV FLUTTER_VERSION="3.35.3"
|
ENV FLUTTER_VERSION="3.35.4"
|
||||||
ENV FLUTTER_HOME=/flutter
|
ENV FLUTTER_HOME=/flutter
|
||||||
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
|
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
|
||||||
|
|
||||||
|
|||||||
@@ -57,28 +57,28 @@ export class MediaRepository {
|
|||||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
|
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
|
||||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message);
|
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
|
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
|
||||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message);
|
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
|
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
|
||||||
return { buffer, format: RawExtractedFormat.Jxl };
|
return { buffer, format: RawExtractedFormat.Jxl };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message);
|
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
|
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
|
||||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.debug('Could not extract preview buffer from image', error.message);
|
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export class MetadataRepository {
|
|||||||
|
|
||||||
readTags(path: string): Promise<ImmichTags> {
|
readTags(path: string): Promise<ImmichTags> {
|
||||||
return this.exiftool.read(path).catch((error) => {
|
return this.exiftool.read(path).catch((error) => {
|
||||||
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
|
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
|
||||||
return {};
|
return {};
|
||||||
}) as Promise<ImmichTags>;
|
}) as Promise<ImmichTags>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ export class AuthService extends BaseService {
|
|||||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } });
|
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } });
|
||||||
}
|
}
|
||||||
} catch (error: Error | any) {
|
} catch (error: Error | any) {
|
||||||
this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack);
|
this.logger.warn(`Unable to sync oauth profile picture: ${error}\n${error?.stack}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,12 +132,12 @@ export class BackupService extends BaseService {
|
|||||||
gzip.stdout.pipe(fileStream);
|
gzip.stdout.pipe(fileStream);
|
||||||
|
|
||||||
pgdump.on('error', (err) => {
|
pgdump.on('error', (err) => {
|
||||||
this.logger.error('Backup failed with error', err);
|
this.logger.error(`Backup failed with error: ${err}`);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
gzip.on('error', (err) => {
|
gzip.on('error', (err) => {
|
||||||
this.logger.error('Gzip failed with error', err);
|
this.logger.error(`Gzip failed with error: ${err}`);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,10 +175,10 @@ export class BackupService extends BaseService {
|
|||||||
});
|
});
|
||||||
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
|
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Database Backup Failure', error);
|
this.logger.error(`Database Backup Failure: ${error}`);
|
||||||
await this.storageRepository
|
await this.storageRepository
|
||||||
.unlink(backupFilePath)
|
.unlink(backupFilePath)
|
||||||
.catch((error) => this.logger.error('Failed to delete failed backup file', error));
|
.catch((error) => this.logger.error(`Failed to delete failed backup file: ${error}`));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export class LibraryService extends BaseService {
|
|||||||
job.paths.map((path) =>
|
job.paths.map((path) =>
|
||||||
this.processEntity(path, library.ownerId, job.libraryId)
|
this.processEntity(path, library.ownerId, job.libraryId)
|
||||||
.then((asset) => assetImports.push(asset))
|
.then((asset) => assetImports.push(asset))
|
||||||
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}`, error)),
|
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class MemoryService extends BaseService {
|
|||||||
try {
|
try {
|
||||||
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
|
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to create memories for ${target.toISO()}`, error);
|
this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`);
|
||||||
}
|
}
|
||||||
// update system metadata even when there is an error to minimize the chance of duplicates
|
// update system metadata even when there is an error to minimize the chance of duplicates
|
||||||
await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, {
|
await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, {
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export class StorageTemplateService extends BaseService {
|
|||||||
|
|
||||||
return destination;
|
return destination;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Unable to get template path for ${filename}`, error);
|
this.logger.error(`Unable to get template path for ${filename}: ${error}`);
|
||||||
return asset.originalPath;
|
return asset.originalPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export class VersionService extends BaseService {
|
|||||||
this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata));
|
this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata));
|
||||||
}
|
}
|
||||||
} catch (error: Error | any) {
|
} catch (error: Error | any) {
|
||||||
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
|
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
|
||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const sendFile = async (
|
|||||||
|
|
||||||
// log non-http errors
|
// log non-http errors
|
||||||
if (error instanceof HttpException === false) {
|
if (error instanceof HttpException === false) {
|
||||||
logger.error(`Unable to send file: ${error.name}`, error.stack);
|
logger.error(`Unable to send file: ${error}`, error.stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.header('Cache-Control', 'none');
|
res.header('Cache-Control', 'none');
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@immich/ui": "^0.28.1",
|
"@immich/ui": "^0.29.0",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.11.5",
|
"@photo-sphere-viewer/core": "^5.11.5",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user