Compare commits

...

48 Commits

Author SHA1 Message Date
midzelis
f9bb469385 refactor(web): extract PhotostreamManager base class 2025-09-18 14:59:25 +00:00
midzelis
aa559f0b30 refactor(web): rename loadMonthGroup to loadSegment API 2025-09-18 14:41:41 +00:00
bo0tzz
c37d13691b feat: shared pre-job action (#20011) 2025-09-18 11:21:06 +02:00
Mert
9ae42106cc fix(mobile): stack row blocking gestures and not showing up (#21854) 2025-09-18 06:16:14 +00:00
Alex
28e9892ed3 fix: show thumbnail instantly when jumping to top of the page (#22163)
* fix: show thumbnail instantly when jumping to top of the page

* pr feedback
2025-09-18 05:26:39 +00:00
shenlong
532ec10d5f refactor: hashing service (#21997)
* download only backup selected assets

* android impl

* fix tests

* limit concurrent hashing to 16

* extension cleanup

* optimized hashing

* hash only selected albums

* remove concurrency limit

* address review comments

* log more info on failure

* add native cancellation

* small batch size on ios, large on android

* fix: get correct resources

* cleanup getResource

* ios better hash cancellation

* handle graceful cancellation android

* do not trigger multiple hashing ops

* ios: fix circular reference, improve cancellation

* kotlin: more cancellation checks

* no need to create result

* cancel previous task

* avoid race condition

* ensure cancellation gets called

* fix cancellation not happening

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-09-17 23:42:37 -05:00
Alex
2411bf8374 fix: asset viewer background isn't shown (#22161)
* fix: asset viewer background isn't shown

* pr feedback
2025-09-17 23:26:16 -05:00
Mert
0b60cc8965 fix(mobile): thumbnail shimmering effect (#22158)
full opacity
2025-09-17 22:29:37 -05:00
Jason Rasmussen
2d816e89ad refactor(web): prefer modal manager (#22152) 2025-09-17 23:23:42 +02:00
Jason Rasmussen
eee94207ce refactor(web): album users modal (#22153) 2025-09-17 17:04:54 -04:00
Jason Rasmussen
dfa38ec3ef fix(web): download panel (#22150) 2025-09-17 15:40:11 -05:00
Jason Rasmussen
edc0698e2a refactor: album edit modal (#22151) 2025-09-17 16:34:12 -04:00
shenlong
0e987352bb fix: do not migrate existing users (#22146)
fix: do not migrate if already on 15+

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-17 13:20:43 -05:00
Jason Rasmussen
98ea3847e5 refactor: server-about-modal (#22138)
* refactor: server-about-modal

* fix: bits-ui scroll lock cleanup
2025-09-17 16:23:23 +00:00
shenlong
53c67f4d71 fix: show delete on device when asset has a local match (#22143)
* fix: show delete on device when asset has a local match

* change test description

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-17 21:48:54 +05:30
Mert
20733bd7df fix(mobile): load original image (#22142)
load original image
2025-09-17 12:14:16 -04:00
Jason Rasmussen
11e72a0f35 refactor: text-primary (#22141) 2025-09-17 12:12:51 -04:00
Jason Rasmussen
53a6724039 refactor: hot module reload component (#22135) 2025-09-17 12:12:37 -04:00
Jason Rasmussen
0b20d1df9f feat(web): toggle theme shortcut (#22139) 2025-09-17 12:12:23 -04:00
Alex
6bb8903b05 chore: revert poll counts from DB rather than using callbacks from library (#22117) (#22140)
Revert "fix: poll counts from DB rather than using callbacks from library (#22117)"

This reverts commit 29fd981587.
2025-09-17 15:41:33 +00:00
Stewart Rand
26e0cb3eb4 fix: Refresh photo after updating featured photo (#21971)
fix: Refresh person photo after setting featured photo

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-09-17 10:22:26 -05:00
shenlong
a8f683ed15 chore(dep): bump flutter to 3.35.4 (#22129)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-17 14:58:35 +00:00
Viktor Mykhailiv
4dfa011eef fix: initial size of bottom sheet (#22085) 2025-09-17 14:41:44 +00:00
Viktor Mykhailiv
0c0bec6ae2 fix: display album image in selection mode (#22087)
* fix: display album image in selection mode

* fix: align MultiSelectStatusButton to display instead of back button in album

* small styling tweak

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-09-17 14:38:25 +00:00
shenlong
61c3f27fdc feat: add configurable backup on charging only and delay settings for android (#22114)
* feat: add configurable on charging only and delay

* Segmented and style the settings

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-09-17 14:13:49 +00:00
Alex
b2ca208dbb fix: ensure background worker is scheduled when the app is dismissed (#22032)
* fix: ensure background worker is scheduled when the app is dismissed

* remove logs

* fix: use native locks (#22081)

* fix: native locks

* use atomicints

* change count check

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-17 09:11:55 -05:00
shenlong
2e945281fc fix: beta migration check (#22092)
fix: beta migration

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-17 08:45:04 -05:00
shenlong
9ac120c772 chore: add mobile codeowner (#22130)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-17 14:08:35 +02:00
Min Idzelis
e6e8ae7c74 chore: remove suppressed warnings (#22120)
chore: remove supressed warnings
2025-09-17 00:06:27 -04:00
shenlong
29fd981587 fix: poll counts from DB rather than using callbacks from library (#22117)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-16 21:13:34 -05:00
Mert
585b74f233 chore(deps): bump flutter to 3.35.3 (#22054)
* bump flutter to 3.35.3

* migrate deprecated code

* linting

* disable custom_lint in ci

* disable custom_lint
2025-09-16 21:10:01 -05:00
Mert
f118bb7e08 fix(mobile): prevent concurrent refresh and processing tasks (#22111)
* task semaphore

* always call setTaskCompleted
2025-09-16 18:06:19 -04:00
renovate[bot]
1710230d61 chore(deps): update dependency @types/nodemailer to v7 (#22047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 22:17:03 +01:00
Jason Rasmussen
2012b07645 refactor: admin settings (#22109) 2025-09-16 17:15:57 -04:00
renovate[bot]
a88a9a7d5e chore(deps): update dependency @faker-js/faker to v10 (#21514)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 21:13:09 +00:00
renovate[bot]
ae539dfdf3 chore(deps): update terraform cloudflare to v4.52.5 (#22044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 22:01:44 +01:00
renovate[bot]
69bb8d834f chore(deps): update github-actions (#22041)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 21:59:58 +01:00
Jason Rasmussen
9693d07a8b refactor: components (#22106) 2025-09-16 16:58:47 -04:00
Jason Rasmussen
453b30069d chore: discord from simple icons (#22104) 2025-09-16 16:33:56 -04:00
Jason Rasmussen
c9daefccc4 refactor: loading spinner (#22103) 2025-09-16 16:22:13 -04:00
Jason Rasmussen
6ffd8e679e refactor: use immich/ui PasswordInput (#22099)
refactor: password-input
2025-09-16 16:09:09 -04:00
Daniel Dietzler
7fe2f19258 chore: migrate to UI lib icon (#22096) 2025-09-16 21:40:43 +02:00
Jason Rasmussen
dac545496e chore: bump immich/ui (#22100) 2025-09-16 15:39:56 -04:00
renovate[bot]
d5b112be53 chore(deps): update ghcr.io/immich-app/postgres:14-vectorchord0.3.0 docker digest to 11ced39 (#22037)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 21:38:14 +02:00
Jason Rasmussen
75322179fd refactor: more elements (#22095) 2025-09-16 15:01:23 -04:00
Jason Rasmussen
3f4b6a8e7c refactor: move more elements (#22093) 2025-09-16 14:47:38 -04:00
Jason Rasmussen
7ce1d73c20 refactor: move components/elements to elements/ (#22091) 2025-09-16 18:31:22 +00:00
Jason Rasmussen
2bf484c91c refactor: timeline components (#22089) 2025-09-16 14:01:12 -04:00
301 changed files with 4616 additions and 2672 deletions

View File

@@ -32,24 +32,18 @@ jobs:
permissions:
contents: read
outputs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
mobile:
- 'mobile/**'
workflow:
- '.github/workflows/build-mobile.yml'
- 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_call' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
force-filters: |
- '.github/workflows/build-mobile.yml'
force-events: 'workflow_call,workflow_dispatch'
build-sign-android:
name: Build and sign Android
@@ -57,7 +51,7 @@ jobs:
permissions:
contents: read
# 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
steps:

View File

@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:1669c75a5542333ff6b03c13d5fd259ea8d798188b84d5d99093d62e4542eb05
image: ghcr.io/immich-app/mdq:main@sha256:d8ae47cf2e6cf4e2559bd57a60b73674fe44f897cba2c2bddff2987a05be10a4
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -50,7 +50,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -63,7 +63,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
uses: github/codeql-action/autobuild@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -76,6 +76,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
with:
category: '/language:${{matrix.language}}'

View File

@@ -20,15 +20,11 @@ jobs:
permissions:
contents: read
outputs:
should_run_server: ${{ steps.found_paths.outputs.server == '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: ${{ steps.check.outputs.should_run }}
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
server:
@@ -38,14 +34,11 @@ jobs:
- 'i18n/**'
machine-learning:
- 'machine-learning/**'
workflow:
- '.github/workflows/docker.yml'
- '.github/workflows/multi-runner-build.yml'
- '.github/actions/image-build'
- 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"
force-filters: |
- '.github/workflows/docker.yml'
- '.github/workflows/multi-runner-build.yml'
- '.github/actions/image-build'
force-events: 'workflow_dispatch,release'
retag_ml:
name: Re-Tag ML
@@ -53,7 +46,7 @@ jobs:
permissions:
contents: read
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
strategy:
matrix:
@@ -82,7 +75,7 @@ jobs:
permissions:
contents: read
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
strategy:
matrix:
@@ -108,7 +101,7 @@ jobs:
machine-learning:
name: Build and Push ML
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
strategy:
fail-fast: false
matrix:
@@ -153,7 +146,7 @@ jobs:
server:
name: Build and Push Server
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
permissions:
contents: read

View File

@@ -18,32 +18,28 @@ jobs:
permissions:
contents: read
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:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
docs:
- 'docs/**'
workflow:
- '.github/workflows/docs-build.yml'
open-api:
- 'open-api/immich-openapi-specs.json'
- 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 == 'release' || github.ref_name == 'main' }}" >> "$GITHUB_OUTPUT"
force-filters: |
- '.github/workflows/docs-build.yml'
force-events: 'release'
force-branches: 'main'
build:
name: Docs Build
needs: pre-job
permissions:
contents: read
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
if: ${{ fromJSON(needs.pre-job.outputs.should_run).docs == true }}
runs-on: ubuntu-latest
defaults:
run:

View File

@@ -119,7 +119,7 @@ jobs:
name: release-apk-signed
- name: Create draft release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -17,28 +17,23 @@ jobs:
permissions:
contents: read
outputs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
mobile:
- 'mobile/**'
workflow:
- '.github/workflows/static_analysis.yml'
- 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 == 'release' }}" >> "$GITHUB_OUTPUT"
force-filters: |
- '.github/workflows/static_analysis.yml'
force-events: 'workflow_dispatch,release'
mobile-dart-analyze:
name: Run Dart Code Analysis
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
permissions:
contents: read
@@ -100,8 +95,9 @@ jobs:
- name: Run dart format
run: make format
- name: Run dart custom_lint
run: dart run custom_lint
# TODO: Re-enable after upgrading custom_lint
# - name: Run dart custom_lint
# run: dart run custom_lint
# TODO: Use https://github.com/CQLabs/dcm-action
- name: Run DCM

View File

@@ -14,23 +14,11 @@ jobs:
permissions:
contents: read
outputs:
should_run_i18n: ${{ steps.found_paths.outputs.i18n == 'true' || steps.should_force.outputs.should_force == 'true' }}
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
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
i18n:
@@ -50,17 +38,16 @@ jobs:
- 'mobile/**'
machine-learning:
- 'machine-learning/**'
workflow:
- '.github/workflows/test.yml'
.github:
- '.github/**'
- 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_OUTPUT"
force-filters: |
- '.github/workflows/test.yml'
force-events: 'workflow_dispatch'
server-unit-tests:
name: Test & Lint Server
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
permissions:
contents: read
@@ -97,7 +84,7 @@ jobs:
cli-unit-tests:
name: Unit Test CLI
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
permissions:
contents: read
@@ -137,7 +124,7 @@ jobs:
cli-unit-tests-win:
name: Unit Test CLI (Windows)
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
permissions:
contents: read
@@ -172,7 +159,7 @@ jobs:
web-lint:
name: Lint Web
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
permissions:
contents: read
@@ -209,7 +196,7 @@ jobs:
web-unit-tests:
name: Test Web
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
permissions:
contents: read
@@ -243,7 +230,7 @@ jobs:
i18n-tests:
name: Test i18n
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
permissions:
contents: read
@@ -281,7 +268,7 @@ jobs:
e2e-tests-lint:
name: End-to-End Lint
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
permissions:
contents: read
@@ -320,7 +307,7 @@ jobs:
server-medium-tests:
name: Medium Tests (Server)
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
permissions:
contents: read
@@ -348,7 +335,7 @@ jobs:
e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI)
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 }}
permissions:
contents: read
@@ -396,7 +383,7 @@ jobs:
e2e-tests-web:
name: End-to-End Tests (Web)
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 }}
permissions:
contents: read
@@ -449,7 +436,7 @@ jobs:
mobile-unit-tests:
name: Unit Test Mobile
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
permissions:
contents: read
@@ -471,7 +458,7 @@ jobs:
ml-unit-tests:
name: Unit Test ML
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
permissions:
contents: read
@@ -507,7 +494,7 @@ jobs:
github-files-formatting:
name: .github Files Formatting
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
permissions:
contents: read
@@ -594,7 +581,7 @@ jobs:
contents: read
services:
postgres:
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:4f7ee144d4738ad02f6d9376defed7a767b748d185d47eba241578c26a63064b
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:da52bbead5d818adaa8077c8dcdaad0aaf93038c31ad8348b51f9f0ec1310a4d
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres

View File

@@ -21,25 +21,24 @@ jobs:
permissions:
contents: read
outputs:
should_run: ${{ steps.found_paths.outputs.i18n == 'true' }}
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
i18n:
- 'i18n/!(en)**\.json'
exclude-branches: 'chore/translations'
skip-force-logic: 'true'
enforce-lock:
name: Check Weblate Lock
needs: [pre-job]
runs-on: ubuntu-latest
permissions: {}
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- name: Bot review status
env:

View File

@@ -4,3 +4,4 @@
/web/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen

View File

@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.3"
constraints = "4.52.3"
version = "4.52.5"
constraints = "4.52.5"
hashes = [
"h1:3jU62KY4Oj3xzMwkTQWon1nlIvFkgTCqI93IzUGaa0c=",
"h1:BWimtYXrvbzbbuoVcyobjQnXjjOb9X69JFTw+GuPxfk=",
"h1:C/KvLEm8dVQ6zG2X4asLDtmw2JW/xu7E8MddtaXniO0=",
"h1:Doo0xcLFf+CnfDWjsA7G1NvSLURuwcgyVy8k0NF1gJA=",
"h1:Gc3FGDtR8lUWsi9VImnnE5/USDXiIwYsv4Hbl+d2lwY=",
"h1:HsDY6s1gup5fW9TeuTUy85QMIld1nDOUFlwsfxIq1ig=",
"h1:MnHkB56E4b/kT6WZigsZJnB5rgnCfDVbrLBNxIsEXPY=",
"h1:O/FUQEqhtknJNdsaMbIBi2pLWBds2VvN5FsTVVntzb0=",
"h1:OKQBynkp0J5DIf5FOl/NR3S2rvh89pY+t5wevYxdTJs=",
"h1:On+vPsYV8U/J/8wFZPXjeAgNJqFFQj42vNOKuNKURkY=",
"h1:SPkrMRJahxK0uum7FnUugbGN/JepHMH8M71DBtYrvG0=",
"h1:bEh1ASPMiin3F36+hTfjMQTBnuDl2DzjzSCdova3JEM=",
"h1:dtIK+x5Q1sh5SMPaHBHXhL9XDIqbRW0EBmVZ+KHQB8E=",
"h1:kZcwWfODMWWyauZ66oaO/X+xXkqBtrbYwfUFEtspwEc=",
"zh:53946fce4a631f1d98c61550821c88edede9169dfe5cc254e09a2ab207f76b3f",
"zh:61654a21f1dd4331492d4ef77e9ebff066bc01e1281f92b925e5697c9138d681",
"zh:6a54e9d129b276f052a2f1b73ad0b8735fe6a7403c6a8f6aa111e525eeefaf35",
"zh:7692374e655c346a630b5a7cd776c5e0b2388900dcd7ab69a3af85d0c31c6c43",
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8fe5b792a4d2b1c3a0e573649642962494faa00299baa6aaf813b9a43203dc02",
"zh:a0f403a4862df90f09de65c6e939d6cfd069a8dda2dd33f82948bf6f5f1124ef",
"zh:a25dc3eb60777b600f8f125d321fe7c50b811c5302b58e9a727ceb749a04e35d",
"zh:a2f2ac7dc703c69d2e8c67c9cb5620b5348cb4fd6b98515fbe3f478517b56602",
"zh:d452e7bd24445ee14166470cf50f3aca566d46cab5f26f1c5c988c0f3106b697",
"zh:e10a52b0294735659eb3f0821ad2006ec097918efe58d31d37a5e3c47efef5f6",
"zh:e28dd0954cef9f05adf4d4b440d6f134f605344dfa56307181996675e6550af2",
"zh:f1e3b2f43a472280442f01ba71a3c06c9167432e553381132ea5c4a77e0b6dd5",
"zh:f71fd63718d38fd43829861e91fe79e16d7b4c7c3d508ae3d077368d89b8e5a0",
"zh:faf8d3da4b819c4ae8e565d2b1a684c6a948a086cb299189a5e7b30b2178409d",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.3"
version = "4.52.5"
}
}
}

View File

@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.3"
constraints = "4.52.3"
version = "4.52.5"
constraints = "4.52.5"
hashes = [
"h1:3jU62KY4Oj3xzMwkTQWon1nlIvFkgTCqI93IzUGaa0c=",
"h1:BWimtYXrvbzbbuoVcyobjQnXjjOb9X69JFTw+GuPxfk=",
"h1:C/KvLEm8dVQ6zG2X4asLDtmw2JW/xu7E8MddtaXniO0=",
"h1:Doo0xcLFf+CnfDWjsA7G1NvSLURuwcgyVy8k0NF1gJA=",
"h1:Gc3FGDtR8lUWsi9VImnnE5/USDXiIwYsv4Hbl+d2lwY=",
"h1:HsDY6s1gup5fW9TeuTUy85QMIld1nDOUFlwsfxIq1ig=",
"h1:MnHkB56E4b/kT6WZigsZJnB5rgnCfDVbrLBNxIsEXPY=",
"h1:O/FUQEqhtknJNdsaMbIBi2pLWBds2VvN5FsTVVntzb0=",
"h1:OKQBynkp0J5DIf5FOl/NR3S2rvh89pY+t5wevYxdTJs=",
"h1:On+vPsYV8U/J/8wFZPXjeAgNJqFFQj42vNOKuNKURkY=",
"h1:SPkrMRJahxK0uum7FnUugbGN/JepHMH8M71DBtYrvG0=",
"h1:bEh1ASPMiin3F36+hTfjMQTBnuDl2DzjzSCdova3JEM=",
"h1:dtIK+x5Q1sh5SMPaHBHXhL9XDIqbRW0EBmVZ+KHQB8E=",
"h1:kZcwWfODMWWyauZ66oaO/X+xXkqBtrbYwfUFEtspwEc=",
"zh:53946fce4a631f1d98c61550821c88edede9169dfe5cc254e09a2ab207f76b3f",
"zh:61654a21f1dd4331492d4ef77e9ebff066bc01e1281f92b925e5697c9138d681",
"zh:6a54e9d129b276f052a2f1b73ad0b8735fe6a7403c6a8f6aa111e525eeefaf35",
"zh:7692374e655c346a630b5a7cd776c5e0b2388900dcd7ab69a3af85d0c31c6c43",
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8fe5b792a4d2b1c3a0e573649642962494faa00299baa6aaf813b9a43203dc02",
"zh:a0f403a4862df90f09de65c6e939d6cfd069a8dda2dd33f82948bf6f5f1124ef",
"zh:a25dc3eb60777b600f8f125d321fe7c50b811c5302b58e9a727ceb749a04e35d",
"zh:a2f2ac7dc703c69d2e8c67c9cb5620b5348cb4fd6b98515fbe3f478517b56602",
"zh:d452e7bd24445ee14166470cf50f3aca566d46cab5f26f1c5c988c0f3106b697",
"zh:e10a52b0294735659eb3f0821ad2006ec097918efe58d31d37a5e3c47efef5f6",
"zh:e28dd0954cef9f05adf4d4b440d6f134f605344dfa56307181996675e6550af2",
"zh:f1e3b2f43a472280442f01ba71a3c06c9167432e553381132ea5c4a77e0b6dd5",
"zh:f71fd63718d38fd43829861e91fe79e16d7b4c7c3d508ae3d077368d89b8e5a0",
"zh:faf8d3da4b819c4ae8e565d2b1a684c6a948a086cb299189a5e7b30b2178409d",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.3"
version = "4.52.5"
}
}
}

View File

@@ -38,7 +38,7 @@ services:
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:11ced39d65a92a54d12890ced6a26cc2003f92697d6f0d4d944b98459dba7138
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres

View File

@@ -533,6 +533,7 @@
"background_backup_running_error": "Background backup is currently running, cannot start manual backup",
"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_options": "Background Options",
"backup": "Backup",
"backup_album_selection_page_albums_device": "Albums on device ({count})",
"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_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
"backup_albums_sync": "Backup albums synchronization",
"backup_all": "All",
"backup_background_service_backup_failed_message": "Failed to backup assets. 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_your_password": "Change your password",
"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_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.",
@@ -1351,6 +1355,7 @@
"name_or_nickname": "Name or nickname",
"network_requirement_photos_upload": "Use cellular data to backup photos",
"network_requirement_videos_upload": "Use cellular data to backup videos",
"network_requirements": "Network Requirements",
"network_requirements_updated": "Network requirements changed, resetting backup queue",
"networking_settings": "Networking",
"networking_subtitle": "Manage the server endpoint settings",

View File

@@ -3,7 +3,7 @@ version = "3.8.2"
backend = "asdf:dart"
[tools.flutter]
version = "3.32.8-stable"
version = "3.35.3-stable"
backend = "asdf:flutter"
[tools."github:CQLabs/homebrew-dcm"]

View File

@@ -1,6 +1,6 @@
[tools]
node = "22.19.0"
flutter = "3.32.8"
flutter = "3.35.4"
pnpm = "10.14.0"
dart = "3.8.2"
@@ -300,7 +300,7 @@ run = "tsc --noEmit"
depends = "web:svelte-kit-sync"
env._.path = "web/node_modules/.bin"
dir = "web"
run = "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/timeline/Timeline.svelte"
run = "svelte-check --no-tsconfig --fail-on-warnings"
[tasks."web:checklist"]
run = [

View File

@@ -1,3 +1,3 @@
{
"flutter": "3.32.8"
"flutter": "3.35.4"
}

View File

@@ -1,8 +1,8 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.32.8",
"dart.flutterSdkPath": ".fvm/versions/3.35.4",
"dart.lineLength": 120,
"[dart]": {
"editor.rulers": [120],
"editor.rulers": [120]
},
"search.exclude": {
"**/.fvm": true

View File

@@ -43,8 +43,9 @@ analyzer:
- lib/**/*.g.dart
- lib/**/*.drift.dart
plugins:
- custom_lint
# TODO: Re-enable after upgrading custom_lint
# plugins:
# - custom_lint
custom_lint:
debug: true

View File

@@ -3,6 +3,7 @@ package app.alextran.immich
import android.app.Application
import androidx.work.Configuration
import androidx.work.WorkManager
import app.alextran.immich.background.BackgroundWorkerApiImpl
class ImmichApp : Application() {
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
// (because of low memory etc.), the backup is never performed.
// As a workaround, we also run a backup check when initializing the application
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
}
}

View File

@@ -3,6 +3,7 @@ package app.alextran.immich
import android.content.Context
import android.os.Build
import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock
import app.alextran.immich.background.BackgroundWorkerApiImpl
import app.alextran.immich.background.BackgroundWorkerFgHostApi
import app.alextran.immich.connectivity.ConnectivityApi
@@ -25,6 +26,7 @@ class MainActivity : FlutterFragmentActivity() {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
flutterEngine.plugins.add(BackgroundEngineLock())
val messenger = flutterEngine.dartExecutor.binaryMessenger
val nativeSyncApiImpl =

View File

@@ -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")
}
}

View File

@@ -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,
val details: Any? = null
) : 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() {
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?) {
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. */
interface BackgroundWorkerFgHostApi {
fun enable()
fun configure(settings: BackgroundWorkerSettings)
fun disable()
companion object {
@@ -89,6 +164,24 @@ interface BackgroundWorkerFgHostApi {
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 {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
if (api != null) {

View File

@@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader
import java.util.concurrent.TimeUnit
@@ -75,6 +76,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
engine = FlutterEngine(ctx)
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
FlutterEngineCache.getInstance()
.put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!)
// Register custom plugins
MainActivity.registerPlugins(ctx, engine!!)
@@ -188,6 +192,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
isComplete = true
engine?.destroy()
engine = null
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
flutterApi = null
notificationManager.cancel(NOTIFICATION_ID)
waitForForegroundPromotion()

View File

@@ -1,6 +1,7 @@
package app.alextran.immich.background
import android.content.Context
import android.content.SharedPreferences
import android.provider.MediaStore
import android.util.Log
import androidx.work.BackoffPolicy
@@ -10,7 +11,7 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
private const val TAG = "BackgroundUploadImpl"
private const val TAG = "BackgroundWorkerApiImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
@@ -19,25 +20,34 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
enqueueMediaObserver(ctx)
}
override fun configure(settings: BackgroundWorkerSettings) {
BackgroundWorkerPreferences(ctx).updateSettings(settings)
enqueueMediaObserver(ctx)
}
override fun disable() {
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
WorkManager.getInstance(ctx).apply {
cancelUniqueWork(OBSERVER_WORKER_NAME)
cancelUniqueWork(BACKGROUND_WORKER_NAME)
}
Log.i(TAG, "Cancelled background upload tasks")
}
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"
fun enqueueMediaObserver(ctx: Context) {
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
.build()
val settings = BackgroundWorkerPreferences(ctx).getSettings()
val constraints = Constraints.Builder().apply {
addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
setTriggerContentUpdateDelay(settings.minimumDelaySeconds, TimeUnit.SECONDS)
setTriggerContentMaxDelay(settings.minimumDelaySeconds * 10, TimeUnit.MINUTES)
setRequiresCharging(settings.requiresCharging)
}.build()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
.setConstraints(constraints)
@@ -45,7 +55,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
WorkManager.getInstance(ctx)
.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) {
@@ -56,9 +69,39 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
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")
}
}
}
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),
)
}
}

View File

@@ -8,7 +8,6 @@ import android.net.Uri
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import android.provider.MediaStore
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
@@ -19,7 +18,6 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DecodeFormat
import java.util.Base64
import java.util.HashMap
import java.util.concurrent.CancellationException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Future
@@ -202,8 +200,10 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
val source = ImageDecoder.createSource(resolver, uri)
signal.throwIfCanceled()
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
val sampleSize = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
decoder.setTargetSampleSize(sampleSize)
if (targetWidth > 0 && targetHeight > 0) {
val sample = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}

View File

@@ -209,6 +209,40 @@ data class SyncDelta (
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() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -227,6 +261,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
SyncDelta.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
@@ -244,11 +283,16 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(131)
writeValue(stream, value.toList())
}
is HashResult -> {
stream.write(132)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi {
fun shouldFullSync(): Boolean
@@ -259,7 +303,8 @@ interface NativeSyncApi {
fun getAlbums(): List<PlatformAlbum>
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
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 {
/** The codec used by NativeSyncApi. */
@@ -402,13 +447,33 @@ interface NativeSyncApi {
}
}
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) {
channel.setMessageHandler { message, reply ->
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 {
listOf(api.hashPaths(pathsArg))
api.cancelHashing()
listOf(null)
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}

View File

@@ -1,14 +1,25 @@
package app.alextran.immich.sync
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.provider.MediaStore
import android.util.Log
import android.util.Base64
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.FileInputStream
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
sealed class AssetResult {
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
@@ -19,8 +30,12 @@ sealed class AssetResult {
open class NativeSyncApiImplBase(context: Context) {
private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
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 =
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
@@ -215,23 +230,74 @@ open class NativeSyncApiImplBase(context: Context) {
.toList()
}
fun hashPaths(paths: List<String>): List<ByteArray?> {
val buffer = ByteArray(HASH_BUFFER_SIZE)
val digest = MessageDigest.getInstance("SHA-1")
fun hashAssets(
assetIds: List<String>,
// 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 {
FileInputStream(path).use { file ->
var bytesRead: Int
while (file.read(buffer).also { bytesRead = it } > 0) {
digest.update(buffer, 0, bytesRead)
val results = assetIds.map { assetId ->
async {
hashSemaphore.withPermit {
ensureActive()
hashAsset(assetId)
}
}
}
digest.digest()
}.awaitAll()
callback(Result.success(results))
} catch (e: CancellationException) {
callback(
Result.failure(
FlutterError(
HASHING_CANCELLED_CODE,
"Hashing operation was cancelled",
null
)
)
)
} catch (e: Exception) {
Log.w(TAG, "Failed to hash file $path: $e")
null
callback(Result.failure(e))
}
}
}
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
}
}

View File

@@ -4,7 +4,6 @@
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/

View File

@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<string>13.0</string>
</dict>
</plist>

View File

@@ -253,7 +253,7 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -133,8 +133,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -521,10 +519,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -553,10 +555,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";

View File

@@ -50,11 +50,119 @@ private func nilOrValue<T>(_ value: Any?) -> 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 {
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 {
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 {
@@ -74,6 +182,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enable() throws
func configure(settings: BackgroundWorkerSettings) throws
func disable() throws
}
@@ -96,6 +205,21 @@ class BackgroundWorkerFgHostApiSetup {
} else {
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)
if let api = api {
disableChannel.setMessageHandler { _, reply in

View File

@@ -5,17 +5,22 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundUploadImpl:enbale Background worker scheduled")
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
}
func configure(settings: BackgroundWorkerSettings) throws {
// Android only
}
func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
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 processingTaskID = "app.alextran.immich.background.processingUpload"
private static let taskSemaphore = DispatchSemaphore(value: 1)
public static func registerBackgroundWorkers() {
BGTaskScheduler.shared.register(
@@ -59,12 +64,18 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
scheduleRefreshWorker()
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
// If another task is running, cede the background time back to the OS
if taskSemaphore.wait(timeout: .now()) == .success {
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
} else {
task.setTaskCompleted(success: false)
}
}
private static func handleBackgroundProcessing(task: BGProcessingTask) {
scheduleProcessingWorker()
taskSemaphore.wait()
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
}
@@ -80,6 +91,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
* - maxSeconds: Optional timeout for the operation in seconds
*/
private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) {
defer { taskSemaphore.signal() }
let semaphore = DispatchSemaphore(value: 0)
var isSuccess = true

View File

@@ -105,7 +105,7 @@ class ThumbnailApiImpl: ThumbnailApi {
var image: UIImage?
Self.imageManager.requestImage(
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,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in

View File

@@ -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 {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
@@ -276,6 +309,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 131:
return SyncDelta.fromList(self.readValue() as! [Any?])
case 132:
return HashResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@@ -293,6 +328,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? SyncDelta {
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
super.writeByte(132)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
@@ -313,6 +351,7 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi {
func shouldFullSync() throws -> Bool
@@ -323,7 +362,8 @@ protocol NativeSyncApi {
func getAlbums() throws -> [PlatformAlbum]
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
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`.
@@ -459,22 +499,38 @@ class NativeSyncApiSetup {
} else {
getAssetsForAlbumChannel.setMessageHandler(nil)
}
let hashPathsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
let hashAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
hashPathsChannel.setMessageHandler { message, reply in
hashAssetsChannel.setMessageHandler { message, reply in
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 {
let result = try api.hashPaths(paths: pathsArg)
reply(wrapResult(result))
try api.cancelHashing()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
hashPathsChannel.setMessageHandler(nil)
cancelHashingChannel.setMessageHandler(nil)
}
}
}

View File

@@ -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 {
private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
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) {
self.defaults = defaults
@@ -96,7 +82,7 @@ class NativeSyncApiImpl: NativeSyncApi {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count {
let album = collections.object(at: i)
// Ignore recovered album
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
continue;
@@ -254,7 +240,7 @@ class NativeSyncApiImpl: NativeSyncApi {
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
}
let result = PHAsset.fetchAssets(in: album, options: options)
if(result.count == 0) {
return []
@@ -267,23 +253,114 @@ class NativeSyncApiImpl: NativeSyncApi {
return assets
}
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] {
return paths.map { path in
guard let file = FileHandle(forReadingAtPath: path) else {
print("Cannot open file: \(path)")
return nil
}
var hasher = Insecure.SHA1()
while autoreleasepool(invoking: {
let chunk = file.readData(ofLength: hashBufferSize)
guard !chunk.isEmpty else { return false }
hasher.update(data: chunk)
return true
}) { }
let digest = hasher.finalize()
return FlutterStandardTypedData(bytes: Data(digest))
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
if let prevTask = hashTask {
prevTask.cancel()
hashTask = nil
}
hashTask = Task { [weak self] in
var missingAssetIds = Set(assetIds)
var assets = [PHAsset]()
assets.reserveCapacity(assetIds.count)
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
if Task.isCancelled {
stop.pointee = true
return
}
missingAssetIds.remove(asset.localIdentifier)
assets.append(asset)
}
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)
})
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:io';
const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1;
const double downloadFailed = -2;
@@ -10,7 +12,7 @@ const int kSyncEventBatchSize = 5000;
const int kFetchLocalAssetsBatchSize = 40000;
// Hash batch limits
const int kBatchHashFileLimit = 256;
final int kBatchHashFileLimit = Platform.isIOS ? 32 : 512;
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
// Secure storage keys

View File

@@ -40,13 +40,12 @@ class AssetService {
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
if (asset.stackId == null) {
return [];
return const [];
}
return _remoteAssetRepository.getStackChildren(asset).then((assets) {
// Include the primary asset in the stack as the first item
return [asset, ...assets];
});
final stack = await _remoteAssetRepository.getStackChildren(asset);
// Include the primary asset in the stack as the first item
return [asset, ...stack];
}
Future<ExifInfo?> getExif(BaseAsset asset) async {

View File

@@ -43,6 +43,17 @@ class BackgroundWorkerFgService {
// TODO: Move this call to native side once old timeline is removed
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();
}
@@ -173,6 +184,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
try {
final backgroundSyncManager = _ref.read(backgroundSyncProvider);
final nativeSyncApi = _ref.read(nativeSyncApiProvider);
_isCleanedUp = true;
_ref.dispose();
@@ -188,7 +200,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_drift.close(),
_driftLogger.close(),
backgroundSyncManager.cancel(),
backgroundSyncManager.cancelLocal(),
nativeSyncApi.cancelHashing(),
];
if (_isar.isOpen) {

View File

@@ -1,20 +1,18 @@
import 'dart:convert';
import 'package:flutter/services.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/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart';
const String _kHashCancelledCode = "HASH_CANCELLED";
class HashService {
final int batchSizeLimit;
final int batchFileLimit;
final int _batchSize;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
final StorageRepository _storageRepository;
final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
@@ -22,37 +20,42 @@ class HashService {
HashService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker,
this.batchSizeLimit = kBatchHashSizeLimit,
this.batchFileLimit = kBatchHashFileLimit,
int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_storageRepository = storageRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi;
_nativeSyncApi = nativeSyncApi,
_batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start();
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getAll(
sortBy: {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum},
);
try {
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums();
for (final album in localAlbums) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing albums.");
break;
}
for (final album in localAlbums) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing albums.");
break;
}
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) {
await _hashAssets(album, assetsToHash);
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) {
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();
@@ -63,8 +66,7 @@ class HashService {
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
int bytesProcessed = 0;
final toHash = <_AssetToPath>[];
final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) {
if (isCancelled) {
@@ -72,21 +74,10 @@ class HashService {
return;
}
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
_log.warning(
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
);
continue;
}
bytesProcessed += await file.length();
toHash.add(_AssetToPath(asset: asset, path: file.path));
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
toHash[asset.id] = asset;
if (toHash.length == _batchSize) {
await _processBatch(album, toHash);
toHash.clear();
bytesProcessed = 0;
}
}
@@ -94,33 +85,36 @@ class HashService {
}
/// 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) {
return;
}
_log.fine("Hashing ${toHash.length} files");
final hashed = <LocalAsset>[];
final hashes = await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList());
final hashed = <String, String>{};
final hashResults = await _nativeSyncApi.hashAssets(
toHash.keys.toList(),
allowNetworkAccess: album.backupSelection == BackupSelection.selected,
);
assert(
hashes.length == toHash.length,
"Hashes length does not match toHash length: ${hashes.length} != ${toHash.length}",
hashResults.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) {
_log.warning("Hashing cancelled. Stopped processing batch.");
return;
}
final hash = hashes[i];
final asset = toHash[i].asset;
if (hash?.length == 20) {
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
final hashResult = hashResults[i];
if (hashResult.hash != null) {
hashed[hashResult.assetId] = hashResult.hash!;
} else {
final asset = toHash[hashResult.assetId];
_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");
await _localAssetRepository.updateHashes(hashed);
await _storageRepository.clearCache();
}
}
class _AssetToPath {
final LocalAsset asset;
final String path;
const _AssetToPath({required this.asset, required this.path});
}

View File

@@ -36,17 +36,17 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
Future<void> updateHashes(Map<String, String> hashes) {
if (hashes.isEmpty) {
return Future.value();
}
return _db.batch((batch) async {
for (final asset in hashes) {
for (final entry in hashes.entries) {
batch.update(
_db.localAssetEntity,
LocalAssetEntityCompanion(checksum: Value(asset.checksum)),
where: (e) => e.id.equals(asset.id),
LocalAssetEntityCompanion(checksum: Value(entry.value)),
where: (e) => e.id.equals(entry.key),
);
}
});

View File

@@ -62,12 +62,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
if (asset.stackId == null) {
return Future.value([]);
final stackId = asset.stackId;
if (stackId == null) {
return Future.value(const []);
}
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)]);
return query.map((row) => row.toDto()).get();

View File

@@ -169,7 +169,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
album.activityEnabled = value;
}
},
activeColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
dense: true,
title: Text(
"comments_and_likes",

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
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:hooks_riverpod/hooks_riverpod.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/translate_extensions.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/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/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
@@ -64,16 +67,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
});
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
@@ -102,6 +95,27 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
onPopInvokedWithResult: (didPop, _) async {
if (!didPop) {
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();
}
},

View File

@@ -112,7 +112,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: showMetadata.value,
onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
activeColor: colorScheme.primary,
activeThumbColor: colorScheme.primary,
dense: true,
title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
);
@@ -122,7 +122,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: allowDownload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
activeColor: colorScheme.primary,
activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"allow_public_user_to_download",
@@ -135,7 +135,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: allowUpload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
activeColor: colorScheme.primary,
activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"allow_public_user_to_upload",
@@ -148,7 +148,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: editExpiry.value,
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
activeColor: colorScheme.primary,
activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"change_expiration_time",

View File

@@ -25,6 +25,57 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
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 {
const _PigeonCodec();
@override
@@ -32,6 +83,9 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is BackgroundWorkerSettings) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
@@ -40,6 +94,8 @@ class _PigeonCodec extends StandardMessageCodec {
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
return BackgroundWorkerSettings.decode(readValue(buffer)!);
default:
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 {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';

View File

@@ -205,6 +205,45 @@ class SyncDelta {
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 {
const _PigeonCodec();
@override
@@ -221,6 +260,9 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is SyncDelta) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is HashResult) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
@@ -235,6 +277,8 @@ class _PigeonCodec extends StandardMessageCodec {
return PlatformAlbum.decode(readValue(buffer)!);
case 131:
return SyncDelta.decode(readValue(buffer)!);
case 132:
return HashResult.decode(readValue(buffer)!);
default:
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 =
'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?>(
pigeonVar_channelName,
pigeonChannelCodec,
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?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
@@ -492,7 +536,30 @@ class NativeSyncApi {
message: 'Host platform returned null value for non-null return value.',
);
} 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;
}
}
}

View File

@@ -208,7 +208,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
activityEnabled.value = value;
await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value);
},
activeColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
dense: true,
title: Text(
"comments_and_likes",

View File

@@ -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/providers/infrastructure/asset.provider.dart';
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset?> {
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset> {
@override
Future<List<RemoteAsset>> build(BaseAsset? asset) async {
if (asset == null || asset is! RemoteAsset || asset.stackId == null) {
return const [];
Future<List<RemoteAsset>> build(BaseAsset asset) {
if (asset is! RemoteAsset || asset.stackId == null) {
return Future.value(const []);
}
return ref.watch(assetServiceProvider).getStack(asset);
@@ -14,4 +14,4 @@ class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAs
}
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset?>(StackChildrenNotifier.new);
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset>(StackChildrenNotifier.new);

View File

@@ -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/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/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';
class AssetStackRow extends ConsumerWidget {
@@ -11,27 +11,25 @@ class AssetStackRow extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (!showControls) {
opacity = 0;
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
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(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: ref
.watch(stackChildrenNotifier(asset))
.when(
data: (state) => SizedBox.square(dimension: 80, child: _StackList(stack: state)),
error: (_, __) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
child: _StackList(stack: stackChildren),
),
);
}
@@ -44,58 +42,77 @@ class _StackList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 30),
itemCount: stack.length,
itemBuilder: (ctx, index) {
final asset = stack[index];
return Padding(
padding: const EdgeInsets.only(right: 5),
child: GestureDetector(
onTap: () {
ref.read(assetViewerProvider.notifier).setStackIndex(index);
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)),
],
),
],
),
),
),
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 5.0,
children: List.generate(stack.length, (i) {
final asset = stack[i];
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
}),
),
);
},
),
),
);
}
}
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,
),
),
);
}
}

View File

@@ -61,6 +61,15 @@ class AssetViewer extends ConsumerStatefulWidget {
ConsumerState createState() => _AssetViewerState();
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
ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed
@@ -107,6 +116,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ImageStream? _prevPreCacheStream;
ImageStream? _nextPreCacheStream;
KeepAliveLink? _stackChildrenKeepAlive;
@override
void initState() {
super.initState();
@@ -117,6 +128,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
reloadSubscription = EventStream.shared.listen(_onEvent);
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
@@ -128,6 +143,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_stackChildrenKeepAlive?.close();
super.dispose();
}
@@ -188,9 +204,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return;
}
AssetViewer.setAsset(ref, asset);
widget.changeAsset(ref, asset);
_precacheAssets(index);
_handleCasting();
_stackChildrenKeepAlive?.close();
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
void _handleCasting() {
@@ -518,7 +536,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
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);

View File

@@ -68,12 +68,16 @@ class AssetViewerState {
stackIndex.hashCode;
}
class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@override
AssetViewerState build() {
return const AssetViewerState();
}
void reset() {
state = const AssetViewerState();
}
void setAsset(BaseAsset? asset) {
if (asset == state.currentAsset) {
return;
@@ -117,6 +121,4 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
}
}
final assetViewerProvider = AutoDisposeNotifierProvider<AssetViewerStateNotifier, AssetViewerState>(
AssetViewerStateNotifier.new,
);
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);

View File

@@ -87,7 +87,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.45,
initialChildSize: widget.minChildSize ?? 0.15,
minChildSize: widget.minChildSize,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,

View File

@@ -84,7 +84,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.45,
initialChildSize: 0.22,
minChildSize: 0.22,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [

View File

@@ -96,7 +96,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
final operation = cachedOperation;
if (operation != null) {
this.cachedOperation = null;
cachedOperation = null;
operation.cancel();
}
}

View File

@@ -4,6 +4,8 @@ import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.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/presentation/widgets/images/image_provider.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 request = this.request = LocalImageRequest(
var request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,
);
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

View File

@@ -326,7 +326,7 @@ class _ThumbnailRenderBox extends RenderBox {
image: _previousImage!,
fit: _fit,
filterQuality: FilterQuality.low,
opacity: 1.0 - _fadeValue,
opacity: 1.0,
);
} else if (_image == null || _fadeValue < 1.0) {
final paint = Paint()..shader = _placeholderGradient.createShader(rect);

View File

@@ -129,7 +129,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
void _onEvent(Event event) {
switch (event) {
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:
_scrollToDate(scrollToDateEvent.date);
case TimelineReloadEvent():
@@ -356,7 +360,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
children: [
timeline,
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!,
],
],

View File

@@ -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/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService(
@@ -35,7 +34,6 @@ final hashServiceProvider = Provider(
(ref) => HashService(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
storageRepository: ref.watch(storageRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
),
);

View File

@@ -50,6 +50,8 @@ enum AppSettingsEnum<T> {
enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, 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);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -16,9 +16,10 @@ class HashService {
required IsarDeviceAssetRepository deviceAssetRepository,
required BackgroundService backgroundService,
this.batchSizeLimit = kBatchHashSizeLimit,
this.batchFileLimit = kBatchHashFileLimit,
int? batchFileLimit,
}) : _deviceAssetRepository = deviceAssetRepository,
_backgroundService = backgroundService;
_backgroundService = backgroundService,
batchFileLimit = batchFileLimit ?? kBatchHashFileLimit;
final IsarDeviceAssetRepository _deviceAssetRepository;
final BackgroundService _backgroundService;

View File

@@ -102,7 +102,7 @@ enum ActionButtonType {
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
context.asset.hasLocal,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,

View File

@@ -62,30 +62,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await Store.populateCache();
}
// Handle migration only for this version
// 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);
}
await handleBetaMigration(version, await _isNewInstallation(db, drift), SyncStreamRepository(drift));
if (targetVersion >= 12) {
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 {
try {
final isarUserCount = await db.users.count();

View File

@@ -75,86 +75,79 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
if (isMultiSelectEnabled) {
return SliverToBoxAdapter(
child: switch (_scrollProgress) {
< 0.8 => const SizedBox(height: 120),
_ => const SizedBox(height: 452),
},
);
} else {
return SliverAppBar(
expandedHeight: 400.0,
floating: false,
pinned: true,
snap: false,
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,
return SliverAppBar(
expandedHeight: 400.0,
floating: false,
pinned: true,
snap: false,
elevation: 0,
leading: isMultiSelectEnabled
? const SizedBox.shrink()
: 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,
),
);
},
),
);
}
}

View File

@@ -13,7 +13,7 @@ class MapSettingsListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
activeColor: context.primaryColor,
activeThumbColor: context.primaryColor,
title: Text(title, style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
value: selected,
onChanged: onChanged,

View File

@@ -350,8 +350,8 @@ class PhotoViewCoreState extends State<PhotoViewCore>
final computedScale = useImageScale ? 1.0 : scale;
final matrix = Matrix4.identity()
..translate(value.position.dx, value.position.dy)
..scale(computedScale)
..translateByDouble(value.position.dx, value.position.dy, 0, 1.0)
..scaleByDouble(computedScale, computedScale, computedScale, 1.0)
..rotateZ(value.rotation);
final Widget customChildLayout = CustomSingleChildLayout(

View File

@@ -13,40 +13,19 @@ class MediaTypePicker extends HookWidget {
Widget build(BuildContext context) {
final selectedMediaType = useState(filter ?? AssetType.other);
return ListView(
shrinkWrap: true,
children: [
RadioListTile(
key: const Key("all"),
title: const Text("all").tr(),
value: AssetType.other,
onChanged: (value) {
selectedMediaType.value = value!;
onSelect(value);
},
groupValue: selectedMediaType.value,
),
RadioListTile(
key: const Key("image"),
title: const Text("image").tr(),
value: AssetType.image,
onChanged: (value) {
selectedMediaType.value = value!;
onSelect(value);
},
groupValue: selectedMediaType.value,
),
RadioListTile(
key: const Key("video"),
title: const Text("video").tr(),
value: AssetType.video,
onChanged: (value) {
selectedMediaType.value = value!;
onSelect(value);
},
groupValue: selectedMediaType.value,
),
],
return RadioGroup(
onChanged: (value) {
selectedMediaType.value = value!;
onSelect(value);
},
groupValue: selectedMediaType.value,
child: Column(
children: [
RadioListTile(key: const Key("all"), title: const Text("all").tr(), value: AssetType.other),
RadioListTile(key: const Key("image"), title: const Text("image").tr(), value: AssetType.image),
RadioListTile(key: const Key("video"), title: const Text("video").tr(), value: AssetType.video),
],
),
);
}
}

View File

@@ -1,14 +1,19 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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/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/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/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/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
@@ -18,12 +23,40 @@ class DriftBackupSettings extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return const SettingsSubPageScaffold(
return SettingsSubPageScaffold(
settings: [
_UseWifiForUploadVideosButton(),
_UseWifiForUploadPhotosButton(),
Divider(indent: 16, endIndent: 16),
_AlbumSyncActionButton(),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
"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 {
const _UseWifiForUploadVideosButton();
class _SettingsSwitchTile extends ConsumerStatefulWidget {
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
Widget build(BuildContext context, WidgetRef ref) {
final valueStream = Store.watch(StoreKey.useWifiForUploadVideos);
ConsumerState createState() => _SettingsSwitchTileState();
}
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(
title: Text(
"videos".t(context: context),
widget.titleKey.t(context: context),
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(
stream: valueStream,
initialData: Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false,
initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
builder: (context, snapshot) {
final value = snapshot.data ?? false;
return Switch(
value: value,
onChanged: (bool newValue) async {
await ref
.read(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.useCellularForUploadVideos, newValue);
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, 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 {
const _UseWifiForUploadPhotosButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final valueStream = Store.watch(StoreKey.useWifiForUploadPhotos);
return ListTile(
title: Text(
"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(
stream: valueStream,
initialData: Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false,
builder: (context, snapshot) {
final value = snapshot.data ?? false;
return Switch(
value: value,
onChanged: (bool newValue) async {
await ref
.read(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.useCellularForUploadPhotos, newValue);
},
);
},
),
return const _SettingsSwitchTile(
appSettingsEnum: AppSettingsEnum.useCellularForUploadPhotos,
titleKey: "photos",
subtitleKey: "network_requirement_photos_upload",
);
}
}
class _BackupOnlyWhenChargingButton extends ConsumerWidget {
const _BackupOnlyWhenChargingButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _SettingsSwitchTile(
appSettingsEnum: AppSettingsEnum.backupRequireCharging,
titleKey: "charging",
subtitleKey: "charging_requirement_mobile_backup",
onChanged: (value) {
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),
),
],
);
}
}

View File

@@ -62,7 +62,7 @@ class BetaTimelineListTile extends ConsumerWidget {
trailing: Switch.adaptive(
value: betaTimelineValue,
onChanged: onSwitchChanged,
activeColor: context.primaryColor,
activeThumbColor: context.primaryColor,
),
onTap: () => onSwitchChanged(!betaTimelineValue),
),

View File

@@ -115,7 +115,7 @@ class PrimaryColorSetting extends HookConsumerWidget {
child: SwitchListTile.adaptive(
contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 20),
dense: true,
activeColor: context.primaryColor,
activeThumbColor: context.primaryColor,
tileColor: context.colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
title: Text(

View File

@@ -9,7 +9,7 @@ class SettingsRadioGroup<T> {
}
class SettingsRadioListTile<T> extends StatelessWidget {
final List<SettingsRadioGroup> groups;
final List<SettingsRadioGroup<T>> groups;
final T groupBy;
final void Function(T?) onRadioChanged;
@@ -17,21 +17,23 @@ class SettingsRadioListTile<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: groups
.map(
(g) => RadioListTile<T>(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
dense: true,
activeColor: context.primaryColor,
title: Text(g.title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
value: g.value,
groupValue: groupBy,
onChanged: onRadioChanged,
controlAffinity: ListTileControlAffinity.trailing,
),
)
.toList(),
return RadioGroup(
groupValue: groupBy,
onChanged: onRadioChanged,
child: Column(
children: groups
.map(
(g) => RadioListTile<T>(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
dense: true,
activeColor: context.primaryColor,
title: Text(g.title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
value: g.value,
controlAffinity: ListTileControlAffinity.trailing,
),
)
.toList(),
),
);
}
}

View File

@@ -40,7 +40,7 @@ class SettingsSwitchListTile extends StatelessWidget {
selectedTileColor: enabled ? null : context.themeData.disabledColor,
value: valueNotifier.value,
onChanged: onSwitchChanged,
activeColor: enabled ? context.primaryColor : context.themeData.disabledColor,
activeThumbColor: enabled ? context.primaryColor : context.themeData.disabledColor,
dense: true,
secondary: icon != null ? Icon(icon!, color: valueNotifier.value ? context.primaryColor : null) : null,
title: Text(

View File

@@ -11,10 +11,19 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile',
),
)
class BackgroundWorkerSettings {
final bool requiresCharging;
final int minimumDelaySeconds;
const BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
}
@HostApi()
abstract class BackgroundWorkerFgHostApi {
void enable();
void configure(BackgroundWorkerSettings settings);
void disable();
}

View File

@@ -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()
abstract class NativeSyncApi {
bool shouldFullSync();
@@ -94,6 +102,9 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<Uint8List?> hashPaths(List<String> paths);
List<HashResult> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false});
void cancelHashing();
}

View File

@@ -1064,26 +1064,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -1877,10 +1877,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.6"
thumbhash:
dependency: "direct main"
description:
@@ -2037,10 +2037,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@@ -2171,4 +2171,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.32.8"
flutter: ">=3.35.4"

View File

@@ -6,7 +6,7 @@ version: 1.142.1+3015
environment:
sdk: '>=3.8.0 <4.0.0'
flutter: 3.32.8
flutter: 3.35.4
isar_version: &isar_version 3.1.8

View File

@@ -1,11 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
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/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/album.stub.dart';
@@ -13,192 +9,137 @@ import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../service.mock.dart';
class MockFile extends Mock implements File {}
void main() {
late HashService sut;
late MockLocalAlbumRepository mockAlbumRepo;
late MockLocalAssetRepository mockAssetRepo;
late MockStorageRepository mockStorageRepo;
late MockNativeSyncApi mockNativeApi;
final sortBy = {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum};
setUp(() {
mockAlbumRepo = MockLocalAlbumRepository();
mockAssetRepo = MockLocalAssetRepository();
mockStorageRepo = MockStorageRepository();
mockNativeApi = MockNativeSyncApi();
sut = HashService(
localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo,
storageRepository: mockStorageRepo,
nativeSyncApi: mockNativeApi,
);
registerFallbackValue(LocalAlbumStub.recent);
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<String, String>{});
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(() => mockStorageRepo.clearCache()).thenAnswer((_) async => {});
});
group('HashService hashAssets', () {
test('skips albums with no assets to hash', () async {
when(
() => mockAlbumRepo.getAll(sortBy: sortBy),
() => mockAlbumRepo.getBackupAlbums(),
).thenAnswer((_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)]);
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(() => mockStorageRepo.getFileForAsset(any()));
verifyNever(() => mockNativeApi.hashPaths(any()));
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
});
group('HashService _hashAssets', () {
test('skips assets without files', () async {
test('skips empty batches', () async {
final album = LocalAlbumStub.recent;
final asset = LocalAssetStub.image1;
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => null);
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(() => mockNativeApi.hashPaths(any()));
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('processes assets when available', () async {
final album = LocalAlbumStub.recent;
final asset = LocalAssetStub.image1;
final mockFile = MockFile();
final hash = Uint8List.fromList(List.generate(20, (i) => i));
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(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [hash]);
when(
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'test-hash')]);
await sut.hashAssets();
verify(() => mockNativeApi.hashPaths(['image-path'])).called(1);
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>;
verify(() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[0].checksum, base64.encode(hash));
expect(captured[asset.id], 'test-hash');
});
test('handles failed hashes', () async {
final album = LocalAlbumStub.recent;
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(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [null]);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
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);
});
test('handles invalid hash length', () async {
test('handles null hash results', () async {
final album = LocalAlbumStub.recent;
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(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
final invalidHash = Uint8List.fromList([1, 2, 3]);
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [invalidHash]);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
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);
});
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 {
const batchSize = 2;
final sut = HashService(
localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo,
storageRepository: mockStorageRepo,
nativeSyncApi: mockNativeApi,
batchSizeLimit: 80,
batchSize: batchSize,
);
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');
final asset3 = LocalAssetStub.image1.copyWith(id: 'image3', name: 'image3.jpg');
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 capturedCalls = <List<String>>[];
final hash = Uint8List.fromList(List.generate(20, (i) => i));
when(() => mockNativeApi.hashPaths(any())).thenAnswer((_) async => [hash]);
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();
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
expect(capturedCalls.length, 2, reason: 'Should make exactly 2 calls to hashAssets');
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);
});
@@ -206,27 +147,43 @@ void main() {
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.getBackupAlbums()).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 validHash = Uint8List.fromList(List.generate(20, (i) => i));
when(() => mockNativeApi.hashPaths(['path-1', 'path-2'])).thenAnswer((_) async => [validHash, null]);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(() => mockNativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
(_) async => [
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
],
);
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.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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -502,6 +502,21 @@ void main() {
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', () {

1373
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,7 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
# Flutter SDK
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
ENV FLUTTER_CHANNEL="stable"
ENV FLUTTER_VERSION="3.32.8"
ENV FLUTTER_VERSION="3.35.4"
ENV FLUTTER_HOME=/flutter
ENV PATH=${PATH}:${FLUTTER_HOME}/bin

View File

@@ -130,7 +130,7 @@
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^22.18.1",
"@types/nodemailer": "^6.4.14",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
"@types/react": "^19.0.0",

View File

@@ -9,7 +9,7 @@
"build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/timeline/Timeline.svelte",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings",
"check:typescript": "tsc --noEmit",
"check:watch": "npm run check:svelte -- --watch",
"check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript",
@@ -28,7 +28,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.27.2",
"@immich/ui": "^0.29.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
@@ -53,6 +53,7 @@
"maplibre-gl": "^5.6.2",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"simple-icons": "^15.15.0",
"socket.io-client": "~4.8.0",
"svelte-gestures": "^5.1.3",
"svelte-i18n": "^4.0.1",
@@ -63,7 +64,7 @@
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@faker-js/faker": "^9.3.0",
"@faker-js/faker": "^10.0.0",
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",

2
web/src/app.d.ts vendored
View File

@@ -36,7 +36,7 @@ type NestedKeys<T, K = keyof T> = K extends keyof T & string
: never;
declare module 'svelte-i18n' {
import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte';
import type { InterpolationValues } from '$lib/elements/format-message.svelte';
import type { Readable } from 'svelte/store';
type Translations = NestedKeys<typeof en>;

View File

@@ -4,7 +4,3 @@ export const sunPath =
export const moonViewBox = '0 0 20 20';
export const sunViewBox = '0 0 20 20';
export const discordPath =
'M81.15,0c-1.2376,2.1973-2.3489,4.4704-3.3591,6.794-9.5975-1.4396-19.3718-1.4396-28.9945,0-.985-2.3236-2.1216-4.5967-3.3591-6.794-9.0166,1.5407-17.8059,4.2431-26.1405,8.0568C2.779,32.5304-1.6914,56.3725.5312,79.8863c9.6732,7.1476,20.5083,12.603,32.0505,16.0884,2.6014-3.4854,4.8998-7.1981,6.8698-11.0623-3.738-1.3891-7.3497-3.1318-10.8098-5.1523.9092-.6567,1.7932-1.3386,2.6519-1.9953,20.281,9.547,43.7696,9.547,64.0758,0,.8587.7072,1.7427,1.3891,2.6519,1.9953-3.4601,2.0457-7.0718,3.7632-10.835,5.1776,1.97,3.8642,4.2683,7.5769,6.8698,11.0623,11.5419-3.4854,22.3769-8.9156,32.0509-16.0631,2.626-27.2771-4.496-50.9172-18.817-71.8548C98.9811,4.2684,90.1918,1.5659,81.1752.0505l-.0252-.0505ZM42.2802,65.4144c-6.2383,0-11.4159-5.6575-11.4159-12.6535s4.9755-12.6788,11.3907-12.6788,11.5169,5.708,11.4159,12.6788c-.101,6.9708-5.026,12.6535-11.3907,12.6535ZM84.3576,65.4144c-6.2637,0-11.3907-5.6575-11.3907-12.6535s4.9755-12.6788,11.3907-12.6788,11.4917,5.708,11.3906,12.6788c-.101,6.9708-5.026,12.6535-11.3906,12.6535Z';
export const discordViewBox = '0 0 126.644 96';

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { Label, Link, Text } from '@immich/ui';
type Props = {
id: string;
title: string;
version?: string;
versionHref?: string;
class?: string;
};
const { id, title, version, versionHref, class: className }: Props = $props();
</script>
<div class={className}>
<Label size="small" color="primary" for={id}>{title}</Label>
<Text size="small" color="muted" {id}>
{#if versionHref}
<Link external href={versionHref}>{version}</Link>
{:else}
{version}
{/if}
</Text>
</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import {
notificationController,
NotificationType,
@@ -10,6 +9,7 @@
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin, type SystemConfigDto } from '@immich/sdk';
@@ -18,7 +18,7 @@
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
@@ -182,7 +182,7 @@
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.oauth_timeout')}
description={$t('admin.oauth_timeout_description')}
required={true}

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;

View File

@@ -1,5 +1,12 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import {
AudioCodec,
CQMode,
@@ -10,19 +17,12 @@
VideoContainer,
type SystemConfigDto,
} from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiHelpCircleOutline } from '@mdi/js';
import { isEqual, sortBy } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
@@ -45,7 +45,7 @@
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<Icon path={mdiHelpCircleOutline} class="inline" size="15" />
<Icon icon={mdiHelpCircleOutline} class="inline" size="15" />
<FormatMessage key="admin.transcoding_codecs_learn_more">
{#snippet children({ tag, message })}
{#if tag === 'h264-link'}

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { Colorspace, ImageFormat, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { t } from 'svelte-i18n';
interface Props {
savedConfig: SystemConfigDto;

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { getJobName } from '$lib/utils';
import { JobName, type SystemConfigDto, type SystemConfigJobDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
@@ -63,7 +63,7 @@
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
description=""
value="1"
value={1}
disabled={true}
title={$t('admin.job_not_concurrency_safe')}
/>

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { LogLevel, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import type { SystemConfigDto } from '@immich/sdk';
import { Button, IconButton } from '@immich/ui';
@@ -13,7 +13,7 @@
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;

Some files were not shown because too many files have changed in this diff Show More