mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 21:01:24 -08:00
Compare commits
1 Commits
chore-full
...
feat/conte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd0a12df37 |
10
.github/mise.toml
vendored
10
.github/mise.toml
vendored
@@ -1,10 +0,0 @@
|
||||
[tasks.install]
|
||||
run = "pnpm install --filter github --frozen-lockfile"
|
||||
|
||||
[tasks.format]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."format-fix"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --write ."
|
||||
4
.github/workflows/build-mobile.yml
vendored
4
.github/workflows/build-mobile.yml
vendored
@@ -165,7 +165,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Publish Android Artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: release-apk-signed
|
||||
path: mobile/build/app/outputs/flutter-apk/*.apk
|
||||
@@ -317,7 +317,7 @@ jobs:
|
||||
security delete-keychain build.keychain || true
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ios-release-ipa
|
||||
path: mobile/ios/Runner.ipa
|
||||
|
||||
4
.github/workflows/cli.yml
vendored
4
.github/workflows/cli.yml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:6b8450bfc06770af1af66bce9bf2ced7d1d9b90df1a59fc4c83a17777a9f6723
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
2
.github/workflows/docs-build.yml
vendored
2
.github/workflows/docs-build.yml
vendored
@@ -85,7 +85,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload build output
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: docs-build-output
|
||||
path: docs/build/
|
||||
|
||||
6
.github/workflows/docs-deploy.yml
vendored
6
.github/workflows/docs-deploy.yml
vendored
@@ -174,7 +174,7 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||
working-directory: 'deployment/modules/cloudflare/docs'
|
||||
run: 'mise run //deployment:tf apply'
|
||||
run: 'mise run tf apply'
|
||||
|
||||
- name: Deploy Docs Subdomain Output
|
||||
id: docs-output
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||
working-directory: 'deployment/modules/cloudflare/docs'
|
||||
run: |
|
||||
mise run //deployment:tf output -- -json | jq -r '
|
||||
mise run tf output -- -json | jq -r '
|
||||
"projectName=\(.pages_project_name.value)",
|
||||
"subdomain=\(.immich_app_branch_subdomain.value)"
|
||||
' >> $GITHUB_OUTPUT
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||
working-directory: 'deployment/modules/cloudflare/docs-release'
|
||||
run: 'mise run //deployment:tf apply'
|
||||
run: 'mise run tf apply'
|
||||
|
||||
- name: Comment
|
||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||
|
||||
2
.github/workflows/docs-destroy.yml
vendored
2
.github/workflows/docs-destroy.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||
working-directory: 'deployment/modules/cloudflare/docs'
|
||||
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
||||
run: 'mise run tf destroy -- -refresh=false'
|
||||
|
||||
- name: Comment
|
||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||
|
||||
4
.github/workflows/prepare-release.yml
vendored
4
.github/workflows/prepare-release.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -563,7 +563,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||
# with:
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
[tasks.install]
|
||||
run = "pnpm install --filter @immich/cli --frozen-lockfile"
|
||||
|
||||
[tasks.build]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "vite build"
|
||||
|
||||
[tasks.test]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "vite"
|
||||
|
||||
[tasks.lint]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||
|
||||
[tasks."lint-fix"]
|
||||
run = { task = "lint --fix" }
|
||||
|
||||
[tasks.format]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."format-fix"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks.check]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "tsc --noEmit"
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/node": "^22.18.13",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
[tools]
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
description = "Format terragrunt files"
|
||||
|
||||
[tasks.tf]
|
||||
run = "terragrunt run --all"
|
||||
description = "Wrapper for terragrunt run-all"
|
||||
dir = "{{cwd}}"
|
||||
|
||||
[tasks."tf:fmt"]
|
||||
run = "tofu fmt -recursive tf/"
|
||||
description = "Format terraform files"
|
||||
|
||||
[tasks."tf:init"]
|
||||
run = { task = "tf init -- -reconfigure" }
|
||||
dir = "{{cwd}}"
|
||||
@@ -10,19 +10,16 @@ Running with a pre-existing Postgres server can unlock powerful administrative f
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You must install pgvector as it is a prerequisite for VectorChord.
|
||||
You must install `pgvector` (`>= 0.7.0, < 1.0.0`), as it is a prerequisite for `vchord`.
|
||||
The easiest way to do this on Debian/Ubuntu is by adding the [PostgreSQL Apt repository][pg-apt] and then
|
||||
running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`).
|
||||
|
||||
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
|
||||
|
||||
:::note Supported versions
|
||||
Immich is known to work with Postgres versions `>= 14, < 19`.
|
||||
:::note
|
||||
Immich is known to work with Postgres versions `>= 14, < 18`.
|
||||
|
||||
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
|
||||
|
||||
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
|
||||
The current accepted range for VectorChord is `>= 0.3, < 0.6`.
|
||||
Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 0.5.0`.
|
||||
:::
|
||||
|
||||
## Specifying the connection URL
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
[tasks.install]
|
||||
run = "pnpm install --filter documentation --frozen-lockfile"
|
||||
|
||||
[tasks.start]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "docusaurus --port 3005"
|
||||
|
||||
[tasks.build]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = [
|
||||
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||
"docusaurus build",
|
||||
]
|
||||
|
||||
[tasks.preview]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "docusaurus serve"
|
||||
|
||||
[tasks.format]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."format-fix"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --write ."
|
||||
@@ -1,29 +0,0 @@
|
||||
[tasks.install]
|
||||
run = "pnpm install --filter immich-e2e --frozen-lockfile"
|
||||
|
||||
[tasks.test]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "vitest --run"
|
||||
|
||||
[tasks."test-web"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "playwright test"
|
||||
|
||||
[tasks.format]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."format-fix"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks.lint]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||
|
||||
[tasks."lint-fix"]
|
||||
run = { task = "lint --fix" }
|
||||
|
||||
[tasks.check]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "tsc --noEmit"
|
||||
@@ -25,7 +25,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/node": "^22.18.13",
|
||||
"@types/oidc-provider": "^9.0.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
|
||||
@@ -52,7 +52,7 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.goto(`/admin/users/${user.userId}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await page.getByRole('button', { name: 'Edit user' }).click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByText('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
@@ -77,7 +77,7 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.goto(`/admin/users/${user.userId}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await page.getByRole('button', { name: 'Edit user' }).click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByText('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
|
||||
14
i18n/en.json
14
i18n/en.json
@@ -475,7 +475,6 @@
|
||||
"allow_edits": "Allow edits",
|
||||
"allow_public_user_to_download": "Allow public user to download",
|
||||
"allow_public_user_to_upload": "Allow public user to upload",
|
||||
"allowed": "Allowed",
|
||||
"alt_text_qr_code": "QR code image",
|
||||
"anti_clockwise": "Anti-clockwise",
|
||||
"api_key": "API Key",
|
||||
@@ -1197,8 +1196,6 @@
|
||||
"import_path": "Import path",
|
||||
"in_albums": "In {count, plural, one {# album} other {# albums}}",
|
||||
"in_archive": "In archive",
|
||||
"in_year": "In {year}",
|
||||
"in_year_selector": "In",
|
||||
"include_archived": "Include archived",
|
||||
"include_shared_albums": "Include shared albums",
|
||||
"include_shared_partner_assets": "Include shared partner assets",
|
||||
@@ -1235,7 +1232,6 @@
|
||||
"language_setting_description": "Select your preferred language",
|
||||
"large_files": "Large Files",
|
||||
"last": "Last",
|
||||
"last_months": "{count, plural, one {Last month} other {Last # months}}",
|
||||
"last_seen": "Last seen",
|
||||
"latest_version": "Latest Version",
|
||||
"latitude": "Latitude",
|
||||
@@ -1318,10 +1314,6 @@
|
||||
"main_menu": "Main menu",
|
||||
"make": "Make",
|
||||
"manage_geolocation": "Manage location",
|
||||
"manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
|
||||
"manage_media_access_settings": "Open settings",
|
||||
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
|
||||
"manage_media_access_title": "Media Management Access",
|
||||
"manage_shared_links": "Manage shared links",
|
||||
"manage_sharing_with_partners": "Manage sharing with partners",
|
||||
"manage_the_app_settings": "Manage the app settings",
|
||||
@@ -1414,7 +1406,6 @@
|
||||
"new_pin_code": "New PIN code",
|
||||
"new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
|
||||
"new_timeline": "New Timeline",
|
||||
"new_update": "New update",
|
||||
"new_user_created": "New user created",
|
||||
"new_version_available": "NEW VERSION AVAILABLE",
|
||||
"newest_first": "Newest first",
|
||||
@@ -1430,7 +1421,6 @@
|
||||
"no_cast_devices_found": "No cast devices found",
|
||||
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
||||
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
|
||||
"no_devices": "No authorized devices",
|
||||
"no_duplicates_found": "No duplicates were found.",
|
||||
"no_exif_info_available": "No exif info available",
|
||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||
@@ -1447,7 +1437,6 @@
|
||||
"no_results_description": "Try a synonym or more general keyword",
|
||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||
"no_uploads_in_progress": "No uploads in progress",
|
||||
"not_allowed": "Not allowed",
|
||||
"not_available": "N/A",
|
||||
"not_in_any_album": "Not in any album",
|
||||
"not_selected": "Not selected",
|
||||
@@ -1558,8 +1547,6 @@
|
||||
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
|
||||
"photos_from_previous_years": "Photos from previous years",
|
||||
"pick_a_location": "Pick a location",
|
||||
"pick_custom_range": "Custom range",
|
||||
"pick_date_range": "Select a date range",
|
||||
"pin_code_changed_successfully": "Successfully changed PIN code",
|
||||
"pin_code_reset_successfully": "Successfully reset PIN code",
|
||||
"pin_code_setup_successfully": "Successfully setup a PIN code",
|
||||
@@ -2040,7 +2027,6 @@
|
||||
"third_party_resources": "Third-Party Resources",
|
||||
"time": "Time",
|
||||
"time_based_memories": "Time-based memories",
|
||||
"time_based_memories_duration": "Number of seconds to display each image.",
|
||||
"timeline": "Timeline",
|
||||
"timezone": "Timezone",
|
||||
"to_archive": "Archive",
|
||||
|
||||
515
mise.toml
515
mise.toml
@@ -1,9 +1,7 @@
|
||||
experimental_monorepo_root = true
|
||||
|
||||
[tools]
|
||||
node = "24.11.0"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.20.0"
|
||||
pnpm = "10.19.0"
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
|
||||
@@ -16,21 +14,514 @@ postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
||||
experimental = true
|
||||
pin = true
|
||||
|
||||
# SDK tasks
|
||||
# .github
|
||||
[tasks."github:install"]
|
||||
run = "pnpm install --filter github --frozen-lockfile"
|
||||
|
||||
[tasks."github:format"]
|
||||
env._.path = "./.github/node_modules/.bin"
|
||||
dir = ".github"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."github:format-fix"]
|
||||
env._.path = "./.github/node_modules/.bin"
|
||||
dir = ".github"
|
||||
run = "prettier --write ."
|
||||
|
||||
# @immich/cli
|
||||
[tasks."cli:install"]
|
||||
run = "pnpm install --filter @immich/cli --frozen-lockfile"
|
||||
|
||||
[tasks."cli:build"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "vite build"
|
||||
|
||||
[tasks."cli:test"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "vite"
|
||||
|
||||
[tasks."cli:lint"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||
|
||||
[tasks."cli:lint-fix"]
|
||||
run = "mise run cli:lint --fix"
|
||||
|
||||
[tasks."cli:format"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."cli:format-fix"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks."cli:check"]
|
||||
env._.path = "./cli/node_modules/.bin"
|
||||
dir = "cli"
|
||||
run = "tsc --noEmit"
|
||||
|
||||
# @immich/sdk
|
||||
[tasks."sdk:install"]
|
||||
dir = "open-api/typescript-sdk"
|
||||
run = "pnpm install --filter @immich/sdk --frozen-lockfile"
|
||||
|
||||
[tasks."sdk:build"]
|
||||
dir = "open-api/typescript-sdk"
|
||||
env._.path = "./node_modules/.bin"
|
||||
env._.path = "./open-api/typescript-sdk/node_modules/.bin"
|
||||
dir = "./open-api/typescript-sdk"
|
||||
run = "tsc"
|
||||
|
||||
# i18n tasks
|
||||
# docs
|
||||
[tasks."docs:install"]
|
||||
run = "pnpm install --filter documentation --frozen-lockfile"
|
||||
|
||||
[tasks."docs:start"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = "docusaurus --port 3005"
|
||||
|
||||
[tasks."docs:build"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = [
|
||||
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||
"docusaurus build",
|
||||
]
|
||||
|
||||
|
||||
[tasks."docs:preview"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = "docusaurus serve"
|
||||
|
||||
|
||||
[tasks."docs:format"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."docs:format-fix"]
|
||||
env._.path = "./docs/node_modules/.bin"
|
||||
dir = "docs"
|
||||
run = "prettier --write ."
|
||||
|
||||
|
||||
# e2e
|
||||
[tasks."e2e:install"]
|
||||
run = "pnpm install --filter immich-e2e --frozen-lockfile"
|
||||
|
||||
[tasks."e2e:test"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "vitest --run"
|
||||
|
||||
[tasks."e2e:test-web"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "playwright test"
|
||||
|
||||
[tasks."e2e:format"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."e2e:format-fix"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks."e2e:lint"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||
|
||||
[tasks."e2e:lint-fix"]
|
||||
run = "mise run e2e:lint --fix"
|
||||
|
||||
[tasks."e2e:check"]
|
||||
env._.path = "./e2e/node_modules/.bin"
|
||||
dir = "e2e"
|
||||
run = "tsc --noEmit"
|
||||
|
||||
# i18n
|
||||
[tasks."i18n:format"]
|
||||
dir = "i18n"
|
||||
run = { task = ":i18n:format-fix" }
|
||||
run = "mise run i18n:format-fix"
|
||||
|
||||
[tasks."i18n:format-fix"]
|
||||
dir = "i18n"
|
||||
run = "pnpm dlx sort-json *.json"
|
||||
run = "pnpm dlx sort-json ./i18n/*.json"
|
||||
|
||||
|
||||
# server
|
||||
[tasks."server:install"]
|
||||
run = "pnpm install --filter immich --frozen-lockfile"
|
||||
|
||||
[tasks."server:build"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "nest build"
|
||||
|
||||
[tasks."server:test"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "vitest --config test/vitest.config.mjs"
|
||||
|
||||
[tasks."server:test-medium"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "vitest --config test/vitest.config.medium.mjs"
|
||||
|
||||
[tasks."server:format"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."server:format-fix"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks."server:lint"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0"
|
||||
|
||||
[tasks."server:lint-fix"]
|
||||
run = "mise run server:lint --fix"
|
||||
|
||||
[tasks."server:check"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "tsc --noEmit"
|
||||
|
||||
[tasks."server:sql"]
|
||||
dir = "server"
|
||||
run = "node ./dist/bin/sync-open-api.js"
|
||||
|
||||
[tasks."server:open-api"]
|
||||
dir = "server"
|
||||
run = "node ./dist/bin/sync-open-api.js"
|
||||
|
||||
[tasks."server:migrations"]
|
||||
dir = "server"
|
||||
run = "node ./dist/bin/migrations.js"
|
||||
description = "Run database migration commands (create, generate, run, debug, or query)"
|
||||
|
||||
[tasks."server:schema-drop"]
|
||||
run = "mise run server:migrations query 'DROP schema public cascade; CREATE schema public;'"
|
||||
|
||||
[tasks."server:schema-reset"]
|
||||
run = "mise run server:schema-drop && mise run server:migrations run"
|
||||
|
||||
[tasks."server:email-dev"]
|
||||
env._.path = "./server/node_modules/.bin"
|
||||
dir = "server"
|
||||
run = "email dev -p 3050 --dir src/emails"
|
||||
|
||||
[tasks."server:checklist"]
|
||||
run = [
|
||||
"mise run server:install",
|
||||
"mise run server:format",
|
||||
"mise run server:lint",
|
||||
"mise run server:check",
|
||||
"mise run server:test-medium --run",
|
||||
"mise run server:test --run",
|
||||
]
|
||||
|
||||
|
||||
# web
|
||||
[tasks."web:install"]
|
||||
run = "pnpm install --filter immich-web --frozen-lockfile"
|
||||
|
||||
[tasks."web:svelte-kit-sync"]
|
||||
env._.path = "./web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "svelte-kit sync"
|
||||
|
||||
[tasks."web:build"]
|
||||
env._.path = "./web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vite build"
|
||||
|
||||
[tasks."web:build-stats"]
|
||||
env.BUILD_STATS = "true"
|
||||
env._.path = "./web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vite build"
|
||||
|
||||
[tasks."web:preview"]
|
||||
env._.path = "./web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vite preview"
|
||||
|
||||
[tasks."web:start"]
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vite dev --host 0.0.0.0 --port 3000"
|
||||
|
||||
[tasks."web:test"]
|
||||
depends = "web:svelte-kit-sync"
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "vitest"
|
||||
|
||||
[tasks."web:format"]
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."web:format-fix"]
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks."web:lint"]
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "eslint . --max-warnings 0 --concurrency 4"
|
||||
|
||||
[tasks."web:lint-fix"]
|
||||
run = "mise run web:lint --fix"
|
||||
|
||||
[tasks."web:check"]
|
||||
depends = "web:svelte-kit-sync"
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "tsc --noEmit"
|
||||
|
||||
[tasks."web:check-svelte"]
|
||||
depends = "web:svelte-kit-sync"
|
||||
env._.path = "web/node_modules/.bin"
|
||||
dir = "web"
|
||||
run = "svelte-check --no-tsconfig --fail-on-warnings"
|
||||
|
||||
[tasks."web:checklist"]
|
||||
run = [
|
||||
"mise run web:install",
|
||||
"mise run web:format",
|
||||
"mise run web:check",
|
||||
"mise run web:test --run",
|
||||
"mise run web:lint",
|
||||
]
|
||||
|
||||
|
||||
# mobile
|
||||
[tasks."mobile:codegen:dart"]
|
||||
alias = "mobile:codegen"
|
||||
description = "Execute build_runner to auto-generate dart code"
|
||||
dir = "mobile"
|
||||
sources = [
|
||||
"pubspec.yaml",
|
||||
"build.yaml",
|
||||
"lib/**/*.dart",
|
||||
"infrastructure/**/*.drift",
|
||||
]
|
||||
outputs = { auto = true }
|
||||
run = "dart run build_runner build --delete-conflicting-outputs"
|
||||
|
||||
[tasks."mobile:codegen:pigeon"]
|
||||
alias = "mobile:pigeon"
|
||||
description = "Generate pigeon platform code"
|
||||
dir = "mobile"
|
||||
depends = [
|
||||
"mobile:pigeon:native-sync",
|
||||
"mobile:pigeon:thumbnail",
|
||||
"mobile:pigeon:background-worker",
|
||||
"mobile:pigeon:background-worker-lock",
|
||||
"mobile:pigeon:connectivity",
|
||||
]
|
||||
|
||||
[tasks."mobile:codegen:translation"]
|
||||
alias = "mobile:translation"
|
||||
description = "Generate translations from i18n JSONs"
|
||||
dir = "mobile"
|
||||
run = [
|
||||
{ task = "i18n:format-fix" },
|
||||
{ tasks = [
|
||||
"mobile:i18n:loader",
|
||||
"mobile:i18n:keys",
|
||||
] },
|
||||
]
|
||||
|
||||
[tasks."mobile:codegen:app-icon"]
|
||||
description = "Generate app icons"
|
||||
dir = "mobile"
|
||||
run = "flutter pub run flutter_launcher_icons:main"
|
||||
|
||||
[tasks."mobile:codegen:splash"]
|
||||
description = "Generate splash screen"
|
||||
dir = "mobile"
|
||||
run = "flutter pub run flutter_native_splash:create"
|
||||
|
||||
[tasks."mobile:test"]
|
||||
description = "Run mobile tests"
|
||||
dir = "mobile"
|
||||
run = "flutter test"
|
||||
|
||||
[tasks."mobile:lint"]
|
||||
description = "Analyze Dart code"
|
||||
dir = "mobile"
|
||||
depends = ["mobile:analyze:dart", "mobile:analyze:dcm"]
|
||||
|
||||
[tasks."mobile:lint-fix"]
|
||||
description = "Auto-fix Dart code"
|
||||
dir = "mobile"
|
||||
depends = ["mobile:analyze:fix:dart", "mobile:analyze:fix:dcm"]
|
||||
|
||||
[tasks."mobile:format"]
|
||||
description = "Format Dart code"
|
||||
dir = "mobile"
|
||||
run = "dart format --set-exit-if-changed $(find lib -name '*.dart' -not \\( -name '*.g.dart' -o -name '*.drift.dart' -o -name '*.gr.dart' \\))"
|
||||
|
||||
[tasks."mobile:build:android"]
|
||||
description = "Build Android release"
|
||||
dir = "mobile"
|
||||
run = "flutter build appbundle"
|
||||
|
||||
[tasks."mobile:drift:migration"]
|
||||
alias = "mobile:migration"
|
||||
description = "Generate database migrations"
|
||||
dir = "mobile"
|
||||
run = "dart run drift_dev make-migrations"
|
||||
|
||||
|
||||
# mobile internal tasks
|
||||
[tasks."mobile:pigeon:native-sync"]
|
||||
description = "Generate native sync API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/native_sync_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/native_sync_api.g.dart",
|
||||
"ios/Runner/Sync/Messages.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/native_sync_api.dart",
|
||||
"dart format lib/platform/native_sync_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:pigeon:thumbnail"]
|
||||
description = "Generate thumbnail API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/thumbnail_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/thumbnail_api.g.dart",
|
||||
"ios/Runner/Images/Thumbnails.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/thumbnail_api.dart",
|
||||
"dart format lib/platform/thumbnail_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:pigeon:background-worker"]
|
||||
description = "Generate background worker API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/background_worker_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/background_worker_api.g.dart",
|
||||
"ios/Runner/Background/BackgroundWorker.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/background_worker_api.dart",
|
||||
"dart format lib/platform/background_worker_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:pigeon:background-worker-lock"]
|
||||
description = "Generate background worker lock API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/background_worker_lock_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/background_worker_lock_api.g.dart",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
||||
"dart format lib/platform/background_worker_lock_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:pigeon:connectivity"]
|
||||
description = "Generate connectivity API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/connectivity_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/connectivity_api.g.dart",
|
||||
"ios/Runner/Connectivity/Connectivity.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
||||
"dart format lib/platform/connectivity_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:i18n:loader"]
|
||||
description = "Generate i18n loader"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["i18n/"]
|
||||
outputs = "lib/generated/codegen_loader.g.dart"
|
||||
run = [
|
||||
"dart run easy_localization:generate -S ../i18n",
|
||||
"dart format lib/generated/codegen_loader.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:i18n:keys"]
|
||||
description = "Generate i18n keys"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["i18n/en.json"]
|
||||
outputs = "lib/generated/intl_keys.g.dart"
|
||||
run = [
|
||||
"dart run bin/generate_keys.dart",
|
||||
"dart format lib/generated/intl_keys.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:analyze:dart"]
|
||||
description = "Run Dart analysis"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
run = "dart analyze --fatal-infos"
|
||||
|
||||
[tasks."mobile:analyze:dcm"]
|
||||
description = "Run Dart Code Metrics"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
run = "dcm analyze lib --fatal-style --fatal-warnings"
|
||||
|
||||
[tasks."mobile:analyze:fix:dart"]
|
||||
description = "Auto-fix Dart analysis"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
run = "dart fix --apply"
|
||||
|
||||
[tasks."mobile:analyze:fix:dcm"]
|
||||
description = "Auto-fix Dart Code Metrics"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
run = "dcm fix lib"
|
||||
|
||||
# docs deployment
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
description = "Format terragrunt files"
|
||||
|
||||
[tasks.tf]
|
||||
run = "terragrunt run --all"
|
||||
description = "Wrapper for terragrunt run-all"
|
||||
dir = "{{cwd}}"
|
||||
|
||||
[tasks."tf:fmt"]
|
||||
run = "tofu fmt -recursive tf/"
|
||||
description = "Format terraform files"
|
||||
|
||||
[tasks."tf:init"]
|
||||
run = "mise run tf init -- -reconfigure"
|
||||
dir = "{{cwd}}"
|
||||
|
||||
@@ -143,7 +143,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
val mediaUrls = call.argument<List<String>>("mediaUrls")
|
||||
if (mediaUrls != null) {
|
||||
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||
moveToTrash(mediaUrls, result)
|
||||
moveToTrash(mediaUrls, result)
|
||||
} else {
|
||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||
}
|
||||
@@ -155,23 +155,15 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
"restoreFromTrash" -> {
|
||||
val fileName = call.argument<String>("fileName")
|
||||
val type = call.argument<Int>("type")
|
||||
val mediaId = call.argument<String>("mediaId")
|
||||
if (fileName != null && type != null) {
|
||||
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||
restoreFromTrash(fileName, type, result)
|
||||
} else {
|
||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||
}
|
||||
} else
|
||||
if (mediaId != null && type != null) {
|
||||
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||
restoreFromTrashById(mediaId, type, result)
|
||||
} else {
|
||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||
}
|
||||
} else {
|
||||
result.error("INVALID_PARAMS", "Required params are not specified.", null)
|
||||
}
|
||||
} else {
|
||||
result.error("INVALID_NAME", "The file name is not specified.", null)
|
||||
}
|
||||
}
|
||||
|
||||
"requestManageMediaPermission" -> {
|
||||
@@ -183,17 +175,6 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
}
|
||||
}
|
||||
|
||||
"hasManageMediaPermission" -> {
|
||||
if (hasManageMediaPermission()) {
|
||||
Log.i("Manage storage permission", "Permission already granted")
|
||||
result.success(true)
|
||||
} else {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
|
||||
"manageMediaPermission" -> requestManageMediaPermission(result)
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -242,48 +223,26 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
uri.let { toggleTrash(listOf(it), false, result) }
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun restoreFromTrashById(mediaId: String, type: Int, result: Result) {
|
||||
val id = mediaId.toLongOrNull()
|
||||
if (id == null) {
|
||||
result.error("INVALID_ID", "The file id is not a valid number: $mediaId", null)
|
||||
return
|
||||
}
|
||||
if (!isInTrash(id)) {
|
||||
result.error("TrashNotFound", "Item with id=$id not found in trash", null)
|
||||
return
|
||||
}
|
||||
|
||||
val uri = ContentUris.withAppendedId(contentUriForType(type), id)
|
||||
|
||||
try {
|
||||
Log.i(TAG, "restoreFromTrashById: uri=$uri (type=$type,id=$id)")
|
||||
restoreUris(listOf(uri), result)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "restoreFromTrashById failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
|
||||
val activity = activityBinding?.activity
|
||||
val contentResolver = context?.contentResolver
|
||||
if (activity == null || contentResolver == null) {
|
||||
result.error("TrashError", "Activity or ContentResolver not available", null)
|
||||
return
|
||||
}
|
||||
val activity = activityBinding?.activity
|
||||
val contentResolver = context?.contentResolver
|
||||
if (activity == null || contentResolver == null) {
|
||||
result.error("TrashError", "Activity or ContentResolver not available", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
|
||||
pendingResult = result // Store for onActivityResult
|
||||
activity.startIntentSenderForResult(
|
||||
pendingIntent.intentSender,
|
||||
trashRequestCode,
|
||||
null, 0, 0, 0
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("TrashError", "Error creating or starting trash request", e)
|
||||
result.error("TrashError", "Error creating or starting trash request", null)
|
||||
try {
|
||||
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
|
||||
pendingResult = result // Store for onActivityResult
|
||||
activity.startIntentSenderForResult(
|
||||
pendingIntent.intentSender,
|
||||
trashRequestCode,
|
||||
null, 0, 0, 0
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("TrashError", "Error creating or starting trash request", e)
|
||||
result.error("TrashError", "Error creating or starting trash request", null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +264,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
|
||||
return ContentUris.withAppendedId(contentUriForType(type), id)
|
||||
// same order as AssetType from dart
|
||||
val contentUri = when (type) {
|
||||
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
else -> queryUri
|
||||
}
|
||||
return ContentUris.withAppendedId(contentUri, id)
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -349,40 +315,6 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun isInTrash(id: Long): Boolean {
|
||||
val contentResolver = context?.contentResolver ?: return false
|
||||
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
val args = Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
|
||||
}
|
||||
return contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
|
||||
?.use { it.moveToFirst() } == true
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun restoreUris(uris: List<Uri>, result: Result) {
|
||||
if (uris.isEmpty()) {
|
||||
result.error("TrashError", "No URIs to restore", null)
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "restoreUris: count=${uris.size}, first=${uris.first()}")
|
||||
toggleTrash(uris, false, result)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun contentUriForType(type: Int): Uri =
|
||||
when (type) {
|
||||
// same order as AssetType from dart
|
||||
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BackgroundServicePlugin"
|
||||
|
||||
@@ -305,7 +305,6 @@ interface NativeSyncApi {
|
||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||
fun cancelHashing()
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
|
||||
companion object {
|
||||
/** The codec used by NativeSyncApi. */
|
||||
@@ -484,21 +483,6 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.getTrashedAssets())
|
||||
} catch (exception: Throwable) {
|
||||
MessagesPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,4 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
|
||||
override fun getMediaChanges(): SyncDelta {
|
||||
throw IllegalStateException("Method not supported on this Android version.")
|
||||
}
|
||||
|
||||
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||
//Method not supported on this Android version.
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package app.alextran.immich.sync
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.RequiresExtension
|
||||
@@ -88,29 +86,4 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
|
||||
// Unmounted volumes are handled in dart when the album is removed
|
||||
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
|
||||
}
|
||||
|
||||
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||
|
||||
val result = LinkedHashMap<String, MutableList<PlatformAsset>>()
|
||||
val volumes = MediaStore.getExternalVolumeNames(ctx)
|
||||
|
||||
for (volume in volumes) {
|
||||
|
||||
val queryArgs = Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, MEDIA_SELECTION)
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, MEDIA_SELECTION_ARGS)
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
}
|
||||
|
||||
getCursor(volume, queryArgs).use { cursor ->
|
||||
getAssets(cursor).forEach { res ->
|
||||
if (res is AssetResult.ValidAsset) {
|
||||
result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.mapValues { it.value.toList() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import androidx.core.database.getStringOrNull
|
||||
@@ -83,16 +81,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
sortOrder,
|
||||
)
|
||||
|
||||
protected fun getCursor(
|
||||
volume: String,
|
||||
queryArgs: Bundle
|
||||
): Cursor? = ctx.contentResolver.query(
|
||||
MediaStore.Files.getContentUri(volume),
|
||||
ASSET_PROJECTION,
|
||||
queryArgs,
|
||||
null
|
||||
)
|
||||
|
||||
protected fun getAssets(cursor: Cursor?): Sequence<AssetResult> {
|
||||
return sequence {
|
||||
cursor?.use { c ->
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v13.json
generated
1
mobile/drift_schemas/main/drift_schema_v13.json
generated
File diff suppressed because one or more lines are too long
@@ -32,9 +32,6 @@
|
||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
|
||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
|
||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
|
||||
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
|
||||
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
|
||||
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -136,11 +133,15 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -152,11 +153,6 @@
|
||||
path = WidgetExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEE084F22EC172080045228E /* Schemas */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Schemas;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -164,9 +160,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
|
||||
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
|
||||
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
|
||||
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -261,7 +254,6 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||
@@ -349,7 +341,6 @@
|
||||
fileSystemSynchronizedGroups = (
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
@@ -428,10 +419,6 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
|
||||
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -543,14 +530,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -579,14 +562,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -1222,43 +1201,6 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/pointfreeco/sqlite-data";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.3.0;
|
||||
};
|
||||
};
|
||||
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/apple/swift-http-structured-headers.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.5.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
FEE084F72EC172460045228E /* SQLiteData */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */;
|
||||
productName = SQLiteData;
|
||||
};
|
||||
FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
|
||||
productName = RawStructuredFieldValues;
|
||||
};
|
||||
FEE084FC2EC1725A0045228E /* StructuredFieldValues */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
|
||||
productName = StructuredFieldValues;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
{
|
||||
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/combine-schedulers",
|
||||
"state" : {
|
||||
"revision" : "5928286acce13def418ec36d05a001a9641086f2",
|
||||
"version" : "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "grdb.swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/groue/GRDB.swift",
|
||||
"state" : {
|
||||
"revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
|
||||
"version" : "7.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sqlite-data",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/sqlite-data",
|
||||
"state" : {
|
||||
"revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-clocks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-clocks",
|
||||
"state" : {
|
||||
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
|
||||
"version" : "1.0.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections",
|
||||
"state" : {
|
||||
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-custom-dump",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-custom-dump",
|
||||
"state" : {
|
||||
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
|
||||
"version" : "1.3.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-dependencies",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-dependencies",
|
||||
"state" : {
|
||||
"revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
|
||||
"version" : "1.10.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-http-structured-headers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-http-structured-headers.git",
|
||||
"state" : {
|
||||
"revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
|
||||
"version" : "1.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-identified-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-identified-collections",
|
||||
"state" : {
|
||||
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-perception",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-perception",
|
||||
"state" : {
|
||||
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
|
||||
"version" : "2.0.9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-sharing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-sharing",
|
||||
"state" : {
|
||||
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
|
||||
"version" : "2.7.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-snapshot-testing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
|
||||
"state" : {
|
||||
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
|
||||
"version" : "1.18.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-structured-queries",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-structured-queries",
|
||||
"state" : {
|
||||
"revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756",
|
||||
"version" : "0.25.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax",
|
||||
"state" : {
|
||||
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
|
||||
"version" : "602.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state" : {
|
||||
"revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
|
||||
"version" : "1.7.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import SQLiteData
|
||||
|
||||
struct Endpoint: Codable {
|
||||
let url: URL
|
||||
let status: Status
|
||||
|
||||
enum Status: String, Codable {
|
||||
case loading, valid, error, unknown
|
||||
}
|
||||
}
|
||||
|
||||
enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||
// MARK: - Int
|
||||
case _version = 0
|
||||
static let version = Typed<Int>(rawValue: ._version)
|
||||
case _deviceIdHash = 3
|
||||
static let deviceIdHash = Typed<Int>(rawValue: ._deviceIdHash)
|
||||
case _backupTriggerDelay = 8
|
||||
static let backupTriggerDelay = Typed<Int>(rawValue: ._backupTriggerDelay)
|
||||
case _tilesPerRow = 103
|
||||
static let tilesPerRow = Typed<Int>(rawValue: ._tilesPerRow)
|
||||
case _groupAssetsBy = 105
|
||||
static let groupAssetsBy = Typed<Int>(rawValue: ._groupAssetsBy)
|
||||
case _uploadErrorNotificationGracePeriod = 106
|
||||
static let uploadErrorNotificationGracePeriod = Typed<Int>(rawValue: ._uploadErrorNotificationGracePeriod)
|
||||
case _thumbnailCacheSize = 110
|
||||
static let thumbnailCacheSize = Typed<Int>(rawValue: ._thumbnailCacheSize)
|
||||
case _imageCacheSize = 111
|
||||
static let imageCacheSize = Typed<Int>(rawValue: ._imageCacheSize)
|
||||
case _albumThumbnailCacheSize = 112
|
||||
static let albumThumbnailCacheSize = Typed<Int>(rawValue: ._albumThumbnailCacheSize)
|
||||
case _selectedAlbumSortOrder = 113
|
||||
static let selectedAlbumSortOrder = Typed<Int>(rawValue: ._selectedAlbumSortOrder)
|
||||
case _logLevel = 115
|
||||
static let logLevel = Typed<Int>(rawValue: ._logLevel)
|
||||
case _mapRelativeDate = 119
|
||||
static let mapRelativeDate = Typed<Int>(rawValue: ._mapRelativeDate)
|
||||
case _mapThemeMode = 124
|
||||
static let mapThemeMode = Typed<Int>(rawValue: ._mapThemeMode)
|
||||
|
||||
// MARK: - String
|
||||
case _assetETag = 1
|
||||
static let assetETag = Typed<String>(rawValue: ._assetETag)
|
||||
case _currentUser = 2
|
||||
static let currentUser = Typed<String>(rawValue: ._currentUser)
|
||||
case _deviceId = 4
|
||||
static let deviceId = Typed<String>(rawValue: ._deviceId)
|
||||
case _accessToken = 11
|
||||
static let accessToken = Typed<String>(rawValue: ._accessToken)
|
||||
case _serverEndpoint = 12
|
||||
static let serverEndpoint = Typed<String>(rawValue: ._serverEndpoint)
|
||||
case _sslClientCertData = 15
|
||||
static let sslClientCertData = Typed<String>(rawValue: ._sslClientCertData)
|
||||
case _sslClientPasswd = 16
|
||||
static let sslClientPasswd = Typed<String>(rawValue: ._sslClientPasswd)
|
||||
case _themeMode = 102
|
||||
static let themeMode = Typed<String>(rawValue: ._themeMode)
|
||||
case _customHeaders = 127
|
||||
static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders)
|
||||
case _primaryColor = 128
|
||||
static let primaryColor = Typed<String>(rawValue: ._primaryColor)
|
||||
case _preferredWifiName = 133
|
||||
static let preferredWifiName = Typed<String>(rawValue: ._preferredWifiName)
|
||||
|
||||
// MARK: - Endpoint
|
||||
case _externalEndpointList = 135
|
||||
static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList)
|
||||
|
||||
// MARK: - URL
|
||||
case _localEndpoint = 134
|
||||
static let localEndpoint = Typed<URL>(rawValue: ._localEndpoint)
|
||||
case _serverUrl = 10
|
||||
static let serverUrl = Typed<URL>(rawValue: ._serverUrl)
|
||||
|
||||
// MARK: - Date
|
||||
case _backupFailedSince = 5
|
||||
static let backupFailedSince = Typed<Date>(rawValue: ._backupFailedSince)
|
||||
|
||||
// MARK: - Bool
|
||||
case _backupRequireWifi = 6
|
||||
static let backupRequireWifi = Typed<Bool>(rawValue: ._backupRequireWifi)
|
||||
case _backupRequireCharging = 7
|
||||
static let backupRequireCharging = Typed<Bool>(rawValue: ._backupRequireCharging)
|
||||
case _autoBackup = 13
|
||||
static let autoBackup = Typed<Bool>(rawValue: ._autoBackup)
|
||||
case _backgroundBackup = 14
|
||||
static let backgroundBackup = Typed<Bool>(rawValue: ._backgroundBackup)
|
||||
case _loadPreview = 100
|
||||
static let loadPreview = Typed<Bool>(rawValue: ._loadPreview)
|
||||
case _loadOriginal = 101
|
||||
static let loadOriginal = Typed<Bool>(rawValue: ._loadOriginal)
|
||||
case _dynamicLayout = 104
|
||||
static let dynamicLayout = Typed<Bool>(rawValue: ._dynamicLayout)
|
||||
case _backgroundBackupTotalProgress = 107
|
||||
static let backgroundBackupTotalProgress = Typed<Bool>(rawValue: ._backgroundBackupTotalProgress)
|
||||
case _backgroundBackupSingleProgress = 108
|
||||
static let backgroundBackupSingleProgress = Typed<Bool>(rawValue: ._backgroundBackupSingleProgress)
|
||||
case _storageIndicator = 109
|
||||
static let storageIndicator = Typed<Bool>(rawValue: ._storageIndicator)
|
||||
case _advancedTroubleshooting = 114
|
||||
static let advancedTroubleshooting = Typed<Bool>(rawValue: ._advancedTroubleshooting)
|
||||
case _preferRemoteImage = 116
|
||||
static let preferRemoteImage = Typed<Bool>(rawValue: ._preferRemoteImage)
|
||||
case _loopVideo = 117
|
||||
static let loopVideo = Typed<Bool>(rawValue: ._loopVideo)
|
||||
case _mapShowFavoriteOnly = 118
|
||||
static let mapShowFavoriteOnly = Typed<Bool>(rawValue: ._mapShowFavoriteOnly)
|
||||
case _selfSignedCert = 120
|
||||
static let selfSignedCert = Typed<Bool>(rawValue: ._selfSignedCert)
|
||||
case _mapIncludeArchived = 121
|
||||
static let mapIncludeArchived = Typed<Bool>(rawValue: ._mapIncludeArchived)
|
||||
case _ignoreIcloudAssets = 122
|
||||
static let ignoreIcloudAssets = Typed<Bool>(rawValue: ._ignoreIcloudAssets)
|
||||
case _selectedAlbumSortReverse = 123
|
||||
static let selectedAlbumSortReverse = Typed<Bool>(rawValue: ._selectedAlbumSortReverse)
|
||||
case _mapwithPartners = 125
|
||||
static let mapwithPartners = Typed<Bool>(rawValue: ._mapwithPartners)
|
||||
case _enableHapticFeedback = 126
|
||||
static let enableHapticFeedback = Typed<Bool>(rawValue: ._enableHapticFeedback)
|
||||
case _dynamicTheme = 129
|
||||
static let dynamicTheme = Typed<Bool>(rawValue: ._dynamicTheme)
|
||||
case _colorfulInterface = 130
|
||||
static let colorfulInterface = Typed<Bool>(rawValue: ._colorfulInterface)
|
||||
case _syncAlbums = 131
|
||||
static let syncAlbums = Typed<Bool>(rawValue: ._syncAlbums)
|
||||
case _autoEndpointSwitching = 132
|
||||
static let autoEndpointSwitching = Typed<Bool>(rawValue: ._autoEndpointSwitching)
|
||||
case _loadOriginalVideo = 136
|
||||
static let loadOriginalVideo = Typed<Bool>(rawValue: ._loadOriginalVideo)
|
||||
case _manageLocalMediaAndroid = 137
|
||||
static let manageLocalMediaAndroid = Typed<Bool>(rawValue: ._manageLocalMediaAndroid)
|
||||
case _readonlyModeEnabled = 138
|
||||
static let readonlyModeEnabled = Typed<Bool>(rawValue: ._readonlyModeEnabled)
|
||||
case _autoPlayVideo = 139
|
||||
static let autoPlayVideo = Typed<Bool>(rawValue: ._autoPlayVideo)
|
||||
case _photoManagerCustomFilter = 1000
|
||||
static let photoManagerCustomFilter = Typed<Bool>(rawValue: ._photoManagerCustomFilter)
|
||||
case _betaPromptShown = 1001
|
||||
static let betaPromptShown = Typed<Bool>(rawValue: ._betaPromptShown)
|
||||
case _betaTimeline = 1002
|
||||
static let betaTimeline = Typed<Bool>(rawValue: ._betaTimeline)
|
||||
case _enableBackup = 1003
|
||||
static let enableBackup = Typed<Bool>(rawValue: ._enableBackup)
|
||||
case _useWifiForUploadVideos = 1004
|
||||
static let useWifiForUploadVideos = Typed<Bool>(rawValue: ._useWifiForUploadVideos)
|
||||
case _useWifiForUploadPhotos = 1005
|
||||
static let useWifiForUploadPhotos = Typed<Bool>(rawValue: ._useWifiForUploadPhotos)
|
||||
case _needBetaMigration = 1006
|
||||
static let needBetaMigration = Typed<Bool>(rawValue: ._needBetaMigration)
|
||||
case _shouldResetSync = 1007
|
||||
static let shouldResetSync = Typed<Bool>(rawValue: ._shouldResetSync)
|
||||
|
||||
struct Typed<T>: RawRepresentable {
|
||||
let rawValue: StoreKey
|
||||
|
||||
@_transparent
|
||||
init(rawValue value: StoreKey) {
|
||||
self.rawValue = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BackupSelection: Int, QueryBindable {
|
||||
case selected, none, excluded
|
||||
}
|
||||
|
||||
enum AvatarColor: Int, QueryBindable {
|
||||
case primary, pink, red, yellow, blue, green, purple, orange, gray, amber
|
||||
}
|
||||
|
||||
enum AlbumUserRole: Int, QueryBindable {
|
||||
case editor, viewer
|
||||
}
|
||||
|
||||
enum MemoryType: Int, QueryBindable {
|
||||
case onThisDay
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import SQLiteData
|
||||
|
||||
enum StoreError: Error {
|
||||
case invalidJSON(String)
|
||||
case invalidURL(String)
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
protocol StoreConvertible {
|
||||
associatedtype StorageType
|
||||
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
|
||||
static func toValue(_ value: Self) throws(StoreError) -> StorageType
|
||||
}
|
||||
|
||||
extension Int: StoreConvertible {
|
||||
static func fromValue(_ value: Int) -> Int { value }
|
||||
static func toValue(_ value: Int) -> Int { value }
|
||||
}
|
||||
|
||||
extension Bool: StoreConvertible {
|
||||
static func fromValue(_ value: Int) -> Bool { value == 1 }
|
||||
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
|
||||
}
|
||||
|
||||
extension Date: StoreConvertible {
|
||||
static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
|
||||
static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
|
||||
}
|
||||
|
||||
extension String: StoreConvertible {
|
||||
static func fromValue(_ value: String) -> String { value }
|
||||
static func toValue(_ value: String) -> String { value }
|
||||
}
|
||||
|
||||
extension URL: StoreConvertible {
|
||||
static func fromValue(_ value: String) throws(StoreError) -> URL {
|
||||
guard let url = URL(string: value) else {
|
||||
throw StoreError.invalidURL(value)
|
||||
}
|
||||
return url
|
||||
}
|
||||
static func toValue(_ value: URL) -> String { value.absoluteString }
|
||||
}
|
||||
|
||||
extension StoreConvertible where Self: Codable, StorageType == String {
|
||||
static var jsonDecoder: JSONDecoder { JSONDecoder() }
|
||||
static var jsonEncoder: JSONEncoder { JSONEncoder() }
|
||||
|
||||
static func fromValue(_ value: String) throws(StoreError) -> Self {
|
||||
do {
|
||||
return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
|
||||
} catch {
|
||||
throw StoreError.invalidJSON(value)
|
||||
}
|
||||
}
|
||||
|
||||
static func toValue(_ value: Self) throws(StoreError) -> String {
|
||||
let encoded: Data
|
||||
do {
|
||||
encoded = try jsonEncoder.encode(value)
|
||||
} catch {
|
||||
throw StoreError.encodingFailed
|
||||
}
|
||||
|
||||
guard let string = String(data: encoded, encoding: .utf8) else {
|
||||
throw StoreError.encodingFailed
|
||||
}
|
||||
return string
|
||||
}
|
||||
}
|
||||
|
||||
extension Array: StoreConvertible where Element: Codable {
|
||||
typealias StorageType = String
|
||||
}
|
||||
|
||||
extension Dictionary: StoreConvertible where Key == String, Value: Codable {
|
||||
typealias StorageType = String
|
||||
}
|
||||
|
||||
class StoreRepository {
|
||||
private let db: DatabasePool
|
||||
|
||||
init(db: DatabasePool) {
|
||||
self.db = db
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == Int {
|
||||
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == String {
|
||||
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == Int {
|
||||
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == String {
|
||||
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == Int {
|
||||
let value = try T.toValue(value)
|
||||
try db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == String {
|
||||
let value = try T.toValue(value)
|
||||
try db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == Int {
|
||||
let value = try T.toValue(value)
|
||||
try await db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == String {
|
||||
let value = try T.toValue(value)
|
||||
try await db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import GRDB
|
||||
import SQLiteData
|
||||
|
||||
@Table("asset_face_entity")
|
||||
struct AssetFace {
|
||||
let id: String
|
||||
let assetId: String
|
||||
let personId: String?
|
||||
let imageWidth: Int
|
||||
let imageHeight: Int
|
||||
let boundingBoxX1: Int
|
||||
let boundingBoxY1: Int
|
||||
let boundingBoxX2: Int
|
||||
let boundingBoxY2: Int
|
||||
let sourceType: String
|
||||
}
|
||||
|
||||
@Table("auth_user_entity")
|
||||
struct AuthUser {
|
||||
let id: String
|
||||
let name: String
|
||||
let email: String
|
||||
let isAdmin: Bool
|
||||
let hasProfileImage: Bool
|
||||
let profileChangedAt: Date
|
||||
let avatarColor: AvatarColor
|
||||
let quotaSizeInBytes: Int
|
||||
let quotaUsageInBytes: Int
|
||||
let pinCode: String?
|
||||
}
|
||||
|
||||
@Table("local_album_entity")
|
||||
struct LocalAlbum {
|
||||
let id: String
|
||||
let backupSelection: BackupSelection
|
||||
let linkedRemoteAlbumId: String?
|
||||
let marker_: Bool?
|
||||
let name: String
|
||||
let isIosSharedAlbum: Bool
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
@Table("local_album_asset_entity")
|
||||
struct LocalAlbumAsset {
|
||||
let id: ID
|
||||
let marker_: String?
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
let assetId: String
|
||||
let albumId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("local_asset_entity")
|
||||
struct LocalAsset {
|
||||
let id: String
|
||||
let checksum: String?
|
||||
let createdAt: Date
|
||||
let durationInSeconds: Int?
|
||||
let height: Int?
|
||||
let isFavorite: Bool
|
||||
let name: String
|
||||
let orientation: String
|
||||
let type: Int
|
||||
let updatedAt: Date
|
||||
let width: Int?
|
||||
}
|
||||
|
||||
@Table("memory_asset_entity")
|
||||
struct MemoryAsset {
|
||||
let id: ID
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
let assetId: String
|
||||
let albumId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("memory_entity")
|
||||
struct Memory {
|
||||
let id: String
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let deletedAt: Date?
|
||||
let ownerId: String
|
||||
let type: MemoryType
|
||||
let data: String
|
||||
let isSaved: Bool
|
||||
let memoryAt: Date
|
||||
let seenAt: Date?
|
||||
let showAt: Date?
|
||||
let hideAt: Date?
|
||||
}
|
||||
|
||||
@Table("partner_entity")
|
||||
struct Partner {
|
||||
let id: ID
|
||||
let inTimeline: Bool
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
let sharedById: String
|
||||
let sharedWithId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("person_entity")
|
||||
struct Person {
|
||||
let id: String
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let ownerId: String
|
||||
let name: String
|
||||
let faceAssetId: String?
|
||||
let isFavorite: Bool
|
||||
let isHidden: Bool
|
||||
let color: String?
|
||||
let birthDate: Date?
|
||||
}
|
||||
|
||||
@Table("remote_album_entity")
|
||||
struct RemoteAlbum {
|
||||
let id: String
|
||||
let createdAt: Date
|
||||
let description: String?
|
||||
let isActivityEnabled: Bool
|
||||
let name: String
|
||||
let order: Int
|
||||
let ownerId: String
|
||||
let thumbnailAssetId: String?
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
@Table("remote_album_asset_entity")
|
||||
struct RemoteAlbumAsset {
|
||||
let id: ID
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
let assetId: String
|
||||
let albumId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("remote_album_user_entity")
|
||||
struct RemoteAlbumUser {
|
||||
let id: ID
|
||||
let role: AlbumUserRole
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
let albumId: String
|
||||
let userId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("remote_asset_entity")
|
||||
struct RemoteAsset {
|
||||
let id: String
|
||||
let checksum: String?
|
||||
let deletedAt: Date?
|
||||
let isFavorite: Int
|
||||
let libraryId: String?
|
||||
let livePhotoVideoId: String?
|
||||
let localDateTime: Date?
|
||||
let orientation: String
|
||||
let ownerId: String
|
||||
let stackId: String?
|
||||
let visibility: Int
|
||||
}
|
||||
|
||||
@Table("remote_exif_entity")
|
||||
struct RemoteExif {
|
||||
@Column(primaryKey: true)
|
||||
let assetId: String
|
||||
let city: String?
|
||||
let state: String?
|
||||
let country: String?
|
||||
let dateTimeOriginal: Date?
|
||||
let description: String?
|
||||
let height: Int?
|
||||
let width: Int?
|
||||
let exposureTime: String?
|
||||
let fNumber: Double?
|
||||
let fileSize: Int?
|
||||
let focalLength: Double?
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
let iso: Int?
|
||||
let make: String?
|
||||
let model: String?
|
||||
let lens: String?
|
||||
let orientation: String?
|
||||
let timeZone: String?
|
||||
let rating: Int?
|
||||
let projectionType: String?
|
||||
}
|
||||
|
||||
@Table("stack_entity")
|
||||
struct Stack {
|
||||
let id: String
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let ownerId: String
|
||||
let primaryAssetId: String
|
||||
}
|
||||
|
||||
@Table("store_entity")
|
||||
struct Store {
|
||||
let id: StoreKey
|
||||
let stringValue: String?
|
||||
let intValue: Int?
|
||||
}
|
||||
|
||||
@Table("user_entity")
|
||||
struct User {
|
||||
let id: String
|
||||
let name: String
|
||||
let email: String
|
||||
let hasProfileImage: Bool
|
||||
let profileChangedAt: Date
|
||||
let avatarColor: AvatarColor
|
||||
}
|
||||
|
||||
@Table("user_metadata_entity")
|
||||
struct UserMetadata {
|
||||
let id: ID
|
||||
let value: Data
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
let userId: String
|
||||
let key: Date
|
||||
}
|
||||
}
|
||||
@@ -364,7 +364,6 @@ protocol NativeSyncApi {
|
||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func cancelHashing() throws
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -533,20 +532,5 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
cancelHashingChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getTrashedAssetsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getTrashedAssetsChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.getTrashedAssets()
|
||||
reply(wrapResult(result))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import CryptoKit
|
||||
|
||||
struct AssetWrapper: Hashable, Equatable {
|
||||
let asset: PlatformAsset
|
||||
|
||||
|
||||
init(with asset: PlatformAsset) {
|
||||
self.asset = asset
|
||||
}
|
||||
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(self.asset.id)
|
||||
}
|
||||
|
||||
|
||||
static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool {
|
||||
return lhs.asset.id == rhs.asset.id
|
||||
}
|
||||
@@ -19,31 +19,31 @@ struct AssetWrapper: Hashable, Equatable {
|
||||
|
||||
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
static let name = "NativeSyncApi"
|
||||
|
||||
|
||||
static func register(with registrar: any FlutterPluginRegistrar) {
|
||||
let instance = NativeSyncApiImpl()
|
||||
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
|
||||
registrar.publish(instance)
|
||||
}
|
||||
|
||||
|
||||
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
|
||||
super.detachFromEngine()
|
||||
}
|
||||
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let changeTokenKey = "immich:changeToken"
|
||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||
private let recoveredAlbumSubType = 1000000219
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 16, *)
|
||||
private func getChangeToken() -> PHPersistentChangeToken? {
|
||||
guard let data = defaults.data(forKey: changeTokenKey) else {
|
||||
@@ -51,7 +51,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 16, *)
|
||||
private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
|
||||
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
|
||||
@@ -59,18 +59,18 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
defaults.set(data, forKey: changeTokenKey)
|
||||
}
|
||||
|
||||
|
||||
func clearSyncCheckpoint() -> Void {
|
||||
defaults.removeObject(forKey: changeTokenKey)
|
||||
}
|
||||
|
||||
|
||||
func checkpointSync() {
|
||||
guard #available(iOS 16, *) else {
|
||||
return
|
||||
}
|
||||
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
|
||||
}
|
||||
|
||||
|
||||
func shouldFullSync() -> Bool {
|
||||
guard #available(iOS 16, *),
|
||||
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
|
||||
@@ -78,36 +78,36 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
|
||||
// Cannot fetch persistent changes
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
func getAlbums() throws -> [PlatformAlbum] {
|
||||
var albums: [PlatformAlbum] = []
|
||||
|
||||
|
||||
albumTypes.forEach { type in
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
||||
options.includeHiddenAssets = false
|
||||
|
||||
|
||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
||||
|
||||
|
||||
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
||||
|
||||
|
||||
var domainAlbum = PlatformAlbum(
|
||||
id: album.localIdentifier,
|
||||
name: album.localizedTitle!,
|
||||
@@ -115,57 +115,57 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
isCloud: isCloud,
|
||||
assetCount: Int64(assets.count)
|
||||
)
|
||||
|
||||
|
||||
if let firstAsset = assets.firstObject {
|
||||
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
|
||||
}
|
||||
|
||||
|
||||
albums.append(domainAlbum)
|
||||
}
|
||||
}
|
||||
return albums.sorted { $0.id < $1.id }
|
||||
}
|
||||
|
||||
|
||||
func getMediaChanges() throws -> SyncDelta {
|
||||
guard #available(iOS 16, *) else {
|
||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
||||
}
|
||||
|
||||
|
||||
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
|
||||
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
|
||||
}
|
||||
|
||||
|
||||
guard let storedToken = getChangeToken() else {
|
||||
// No token exists, definitely need a full sync
|
||||
print("MediaManager::getMediaChanges: No token found")
|
||||
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
|
||||
}
|
||||
|
||||
|
||||
let currentToken = PHPhotoLibrary.shared().currentChangeToken
|
||||
if storedToken == currentToken {
|
||||
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
|
||||
|
||||
|
||||
var updatedAssets: Set<AssetWrapper> = []
|
||||
var deletedAssets: Set<String> = []
|
||||
|
||||
|
||||
for change in changes {
|
||||
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
|
||||
|
||||
|
||||
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
|
||||
deletedAssets.formUnion(details.deletedLocalIdentifiers)
|
||||
|
||||
|
||||
if (updated.isEmpty) { continue }
|
||||
|
||||
|
||||
let options = PHFetchOptions()
|
||||
options.includeHiddenAssets = false
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
|
||||
for i in 0..<result.count {
|
||||
let asset = result.object(at: i)
|
||||
|
||||
|
||||
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
|
||||
let predicate = PlatformAsset(
|
||||
id: asset.localIdentifier,
|
||||
@@ -178,25 +178,25 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
|
||||
updatedAssets.insert(domainAsset)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let updates = Array(updatedAssets.map { $0.asset })
|
||||
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
|
||||
guard !assets.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
|
||||
var albumAssets: [String: [String]] = [:]
|
||||
|
||||
|
||||
for type in albumTypes {
|
||||
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
||||
collections.enumerateObjects { (album, _, _) in
|
||||
@@ -211,13 +211,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
return albumAssets
|
||||
}
|
||||
|
||||
|
||||
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
|
||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||
guard let album = collections.firstObject else {
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
var ids: [String] = []
|
||||
let options = PHFetchOptions()
|
||||
options.includeHiddenAssets = false
|
||||
@@ -227,13 +227,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
|
||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||
guard let album = collections.firstObject else {
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
|
||||
let options = PHFetchOptions()
|
||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||
@@ -241,32 +241,32 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
||||
return Int64(assets.count)
|
||||
}
|
||||
|
||||
|
||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
|
||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||
guard let album = collections.firstObject else {
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
let options = PHFetchOptions()
|
||||
options.includeHiddenAssets = false
|
||||
if(updatedTimeCond != nil) {
|
||||
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||
}
|
||||
|
||||
|
||||
let result = getAssetsFromAlbum(in: album, options: options)
|
||||
if(result.count == 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
var assets: [PlatformAsset] = []
|
||||
result.enumerateObjects { (asset, _, _) in
|
||||
assets.append(asset.toPlatformAsset())
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
||||
if let prevTask = hashTask {
|
||||
prevTask.cancel()
|
||||
@@ -284,11 +284,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
missingAssetIds.remove(asset.localIdentifier)
|
||||
assets.append(asset)
|
||||
}
|
||||
|
||||
|
||||
if Task.isCancelled {
|
||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||
}
|
||||
|
||||
|
||||
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
||||
var results = [HashResult]()
|
||||
results.reserveCapacity(assets.count)
|
||||
@@ -301,28 +301,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for await result in taskGroup {
|
||||
guard let result = result else {
|
||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||
}
|
||||
results.append(result)
|
||||
}
|
||||
|
||||
|
||||
for missing in missingAssetIds {
|
||||
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
||||
}
|
||||
|
||||
|
||||
return self?.completeWhenActive(for: completion, with: .success(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func cancelHashing() {
|
||||
hashTask?.cancel()
|
||||
hashTask = nil
|
||||
}
|
||||
|
||||
|
||||
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
|
||||
class RequestRef {
|
||||
var id: PHAssetResourceDataRequestID?
|
||||
@@ -332,21 +332,21 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
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,
|
||||
@@ -377,11 +377,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
PHAssetResourceManager.default().cancelDataRequest(requestId)
|
||||
})
|
||||
}
|
||||
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||
}
|
||||
|
||||
|
||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
// Ensure to actually getting all assets for the Recents album
|
||||
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
||||
|
||||
@@ -58,6 +58,3 @@ const int kPhotoTabIndex = 0;
|
||||
const int kSearchTabIndex = 1;
|
||||
const int kAlbumTabIndex = 2;
|
||||
const int kLibraryTabIndex = 3;
|
||||
|
||||
// Workaround for SQLite's variable limit (SQLITE_MAX_VARIABLE_NUMBER = 32766)
|
||||
const int kDriftMaxChunk = 32000;
|
||||
|
||||
@@ -2,10 +2,8 @@ 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/extensions/platform_extensions.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/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
@@ -15,7 +13,6 @@ class HashService {
|
||||
final int _batchSize;
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final bool Function()? _cancelChecker;
|
||||
final _log = Logger('HashService');
|
||||
@@ -23,13 +20,11 @@ class HashService {
|
||||
HashService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
bool Function()? cancelChecker,
|
||||
int? batchSize,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_cancelChecker = cancelChecker,
|
||||
_nativeSyncApi = nativeSyncApi,
|
||||
_batchSize = batchSize ?? kBatchHashFileLimit;
|
||||
@@ -54,14 +49,6 @@ class HashService {
|
||||
await _hashAssets(album, assetsToHash);
|
||||
}
|
||||
}
|
||||
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
||||
final backupAlbumIds = localAlbums.map((e) => e.id);
|
||||
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
||||
if (trashedToHash.isNotEmpty) {
|
||||
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||
}
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _kHashCancelledCode) {
|
||||
_log.warning("Hashing cancelled by platform");
|
||||
@@ -78,7 +65,7 @@ class HashService {
|
||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
|
||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
||||
final toHash = <String, LocalAsset>{};
|
||||
|
||||
for (final asset in assetsToHash) {
|
||||
@@ -89,16 +76,16 @@ class HashService {
|
||||
|
||||
toHash[asset.id] = asset;
|
||||
if (toHash.length == _batchSize) {
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
await _processBatch(album, toHash);
|
||||
toHash.clear();
|
||||
}
|
||||
}
|
||||
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
await _processBatch(album, toHash);
|
||||
}
|
||||
|
||||
/// Processes a batch of assets.
|
||||
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
|
||||
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
|
||||
if (toHash.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -133,10 +120,7 @@ class HashService {
|
||||
}
|
||||
|
||||
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
||||
if (isTrashed) {
|
||||
await _trashedLocalAssetRepository.updateHashes(hashed);
|
||||
} else {
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,9 @@ import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -19,34 +14,15 @@ import 'package:logging/logging.dart';
|
||||
class LocalSyncService {
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
LocalSyncService({required DriftLocalAlbumRepository localAlbumRepository, required NativeSyncApi nativeSyncApi})
|
||||
: _localAlbumRepository = localAlbumRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _syncTrashedAssets();
|
||||
} else {
|
||||
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
}
|
||||
}
|
||||
if (full || await _nativeSyncApi.shouldFullSync()) {
|
||||
_log.fine("Full sync request from ${full ? "user" : "native"}");
|
||||
return await fullSync();
|
||||
@@ -93,6 +69,7 @@ class LocalSyncService {
|
||||
await updateAlbum(dbAlbum, album);
|
||||
}
|
||||
}
|
||||
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
} catch (e, s) {
|
||||
_log.severe("Error performing device sync", e, s);
|
||||
@@ -296,48 +273,6 @@ class LocalSyncService {
|
||||
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
|
||||
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
|
||||
}
|
||||
|
||||
Future<void> _syncTrashedAssets() async {
|
||||
final trashedAssetMap = await _nativeSyncApi.getTrashedAssets();
|
||||
await processTrashedAssets(trashedAssetMap);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
|
||||
if (trashedAssetMap.isEmpty) {
|
||||
_log.info("syncTrashedAssets, No trashed assets found");
|
||||
}
|
||||
final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand(
|
||||
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
|
||||
);
|
||||
|
||||
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
|
||||
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
|
||||
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
||||
}
|
||||
|
||||
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
}
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<PlatformAlbum> {
|
||||
@@ -355,26 +290,20 @@ extension on Iterable<PlatformAlbum> {
|
||||
|
||||
extension on Iterable<PlatformAsset> {
|
||||
List<LocalAsset> toLocalAssets() {
|
||||
return map((e) => e.toLocalAsset()).toList();
|
||||
}
|
||||
|
||||
Iterable<TrashedAsset> toTrashedAssets(String albumId) {
|
||||
return map((e) => (albumId: albumId, asset: e.toLocalAsset()));
|
||||
return map(
|
||||
(e) => LocalAsset(
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
checksum: null,
|
||||
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
|
||||
createdAt: tryFromSecondsSinceEpoch(e.createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
width: e.width,
|
||||
height: e.height,
|
||||
durationInSeconds: e.durationInSeconds,
|
||||
orientation: e.orientation,
|
||||
isFavorite: e.isFavorite,
|
||||
),
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
|
||||
extension on PlatformAsset {
|
||||
LocalAsset toLocalAsset() => LocalAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
checksum: null,
|
||||
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
|
||||
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
width: width,
|
||||
height: height,
|
||||
durationInSeconds: durationInSeconds,
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -18,26 +11,14 @@ class SyncStreamService {
|
||||
|
||||
final SyncApiRepository _syncApiRepository;
|
||||
final SyncStreamRepository _syncStreamRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final bool Function()? _cancelChecker;
|
||||
|
||||
SyncStreamService({
|
||||
required SyncApiRepository syncApiRepository,
|
||||
required SyncStreamRepository syncStreamRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
bool Function()? cancelChecker,
|
||||
}) : _syncApiRepository = syncApiRepository,
|
||||
_syncStreamRepository = syncStreamRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_cancelChecker = cancelChecker;
|
||||
|
||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||
@@ -102,18 +83,7 @@ class SyncStreamService {
|
||||
case SyncEntityType.partnerDeleteV1:
|
||||
return _syncStreamRepository.deletePartnerV1(data.cast());
|
||||
case SyncEntityType.assetV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
|
||||
await _applyRemoteRestoreToLocal();
|
||||
} else {
|
||||
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
}
|
||||
}
|
||||
return;
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast());
|
||||
case SyncEntityType.assetDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
||||
case SyncEntityType.assetExifV1:
|
||||
@@ -242,36 +212,4 @@ class SyncStreamService {
|
||||
_logger.severe("Error processing AssetUploadReadyV1 websocket batch events", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
|
||||
if (checksums.isEmpty) {
|
||||
return Future.value();
|
||||
} else {
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
}
|
||||
} else {
|
||||
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyRemoteRestoreToLocal() async {
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_logger.info("No remote assets found for restoration");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)')
|
||||
class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
const TrashedLocalAssetEntity();
|
||||
|
||||
TextColumn get id => text()();
|
||||
|
||||
TextColumn get albumId => text()();
|
||||
|
||||
TextColumn get checksum => text().nullable()();
|
||||
|
||||
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
|
||||
|
||||
IntColumn get orientation => integer().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id, albumId};
|
||||
}
|
||||
|
||||
extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityData {
|
||||
LocalAsset toLocalAsset() => LocalAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
checksum: checksum,
|
||||
type: type,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
durationInSeconds: durationInSeconds,
|
||||
isFavorite: isFavorite,
|
||||
height: height,
|
||||
width: width,
|
||||
orientation: orientation,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
|
||||
@@ -63,7 +62,6 @@ class IsarDatabaseRepository implements IDatabaseRepository {
|
||||
PersonEntity,
|
||||
AssetFaceEntity,
|
||||
StoreEntity,
|
||||
TrashedLocalAssetEntity,
|
||||
],
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
@@ -95,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 13;
|
||||
int get schemaVersion => 12;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -180,11 +178,6 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
);
|
||||
}
|
||||
},
|
||||
from12To13: (m, v13) async {
|
||||
await m.create(v13.trashedLocalAssetEntity);
|
||||
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
|
||||
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -37,11 +37,9 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da
|
||||
as i17;
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
|
||||
as i18;
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
|
||||
as i19;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
as i20;
|
||||
import 'package:drift/internal/modular.dart' as i21;
|
||||
as i19;
|
||||
import 'package:drift/internal/modular.dart' as i20;
|
||||
|
||||
abstract class $Drift extends i0.GeneratedDatabase {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
@@ -79,11 +77,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
late final i17.$AssetFaceEntityTable assetFaceEntity = i17
|
||||
.$AssetFaceEntityTable(this);
|
||||
late final i18.$StoreEntityTable storeEntity = i18.$StoreEntityTable(this);
|
||||
late final i19.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i19
|
||||
.$TrashedLocalAssetEntityTable(this);
|
||||
i20.MergedAssetDrift get mergedAssetDrift => i21.ReadDatabaseContainer(
|
||||
i19.MergedAssetDrift get mergedAssetDrift => i20.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i20.MergedAssetDrift>(i20.MergedAssetDrift.new);
|
||||
).accessor<i19.MergedAssetDrift>(i19.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@@ -112,10 +108,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
i11.idxLatLng,
|
||||
i19.idxTrashedLocalAssetChecksum,
|
||||
i19.idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
@override
|
||||
i0.StreamQueryUpdateRules
|
||||
@@ -343,9 +336,4 @@ class $DriftManager {
|
||||
i17.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
|
||||
i18.$$StoreEntityTableTableManager get storeEntity =>
|
||||
i18.$$StoreEntityTableTableManager(_db, _db.storeEntity);
|
||||
i19.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
|
||||
i19.$$TrashedLocalAssetEntityTableTableManager(
|
||||
_db,
|
||||
_db.trashedLocalAssetEntity,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5037,454 +5037,6 @@ final class Schema12 extends i0.VersionedSchema {
|
||||
);
|
||||
}
|
||||
|
||||
final class Schema13 extends i0.VersionedSchema {
|
||||
Schema13({required super.database}) : super(version: 13);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAssetChecksum,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
idxLatLng,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
late final Shape20 userEntity = Shape20(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_91,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape17 remoteAssetEntity = Shape17(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_86,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape3 stackEntity = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape2 localAssetEntity = Shape2(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape9 remoteAlbumEntity = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_56,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_57,
|
||||
_column_58,
|
||||
_column_59,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape19 localAlbumEntity = Shape19(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_5,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_90,
|
||||
_column_33,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_34, _column_35, _column_33],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
late final Shape21 authUserEntity = Shape21(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_2,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_92,
|
||||
_column_93,
|
||||
_column_7,
|
||||
_column_94,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_25, _column_26, _column_27],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 partnerEntity = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_28, _column_29, _column_30],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 remoteExifEntity = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
_column_38,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_41,
|
||||
_column_11,
|
||||
_column_10,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_48,
|
||||
_column_49,
|
||||
_column_50,
|
||||
_column_51,
|
||||
_column_52,
|
||||
_column_53,
|
||||
_column_54,
|
||||
_column_55,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_36, _column_60],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_60, _column_25, _column_61],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 memoryEntity = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_18,
|
||||
_column_15,
|
||||
_column_8,
|
||||
_column_62,
|
||||
_column_63,
|
||||
_column_64,
|
||||
_column_65,
|
||||
_column_66,
|
||||
_column_67,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_36, _column_68],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape14 personEntity = Shape14(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_1,
|
||||
_column_69,
|
||||
_column_71,
|
||||
_column_72,
|
||||
_column_73,
|
||||
_column_74,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape15 assetFaceEntity = Shape15(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_76,
|
||||
_column_77,
|
||||
_column_78,
|
||||
_column_79,
|
||||
_column_80,
|
||||
_column_81,
|
||||
_column_82,
|
||||
_column_83,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_87, _column_88, _column_89],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape23 trashedLocalAssetEntity = Shape23(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_95,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape23 extends i0.VersionedTable {
|
||||
Shape23({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationInSeconds =>
|
||||
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get albumId =>
|
||||
columnsByName['album_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_95(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'album_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -5497,7 +5049,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -5556,11 +5107,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from11To12(migrator, schema);
|
||||
return 12;
|
||||
case 12:
|
||||
final schema = Schema13(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from12To13(migrator, schema);
|
||||
return 13;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -5579,7 +5125,6 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -5593,6 +5138,5 @@ i1.OnUpgrade stepByStep({
|
||||
from9To10: from9To10,
|
||||
from10To11: from10To11,
|
||||
from11To12: from11To12,
|
||||
from12To13: from12To13,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.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/entities/local_album.entity.dart';
|
||||
@@ -10,7 +8,6 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
const DriftLocalAssetRepository(this._db) : super(_db);
|
||||
|
||||
SingleOrNullSelectable<LocalAsset?> _assetSelectable(String id) {
|
||||
@@ -98,32 +95,4 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
return query.map((localAlbum) => localAlbum.toDto()).get();
|
||||
}
|
||||
|
||||
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
|
||||
if (checksums.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final result = <String, List<LocalAsset>>{};
|
||||
|
||||
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
|
||||
final rows =
|
||||
await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
])..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.localAssetEntity.checksum.isIn(slice),
|
||||
))
|
||||
.get();
|
||||
|
||||
for (final row in rows) {
|
||||
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
|
||||
final assetData = row.readTable(_db.localAssetEntity);
|
||||
final asset = assetData.toDto();
|
||||
(result[albumId] ??= <LocalAsset>[]).add(asset);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
const RemoteAssetRepository(this._db) : super(_db);
|
||||
|
||||
/// For testing purposes
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.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/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
typedef TrashedAsset = ({String albumId, LocalAsset asset});
|
||||
|
||||
class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
const DriftTrashedLocalAssetRepository(this._db) : super(_db);
|
||||
|
||||
Future<void> updateHashes(Map<String, String> hashes) {
|
||||
if (hashes.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
return _db.batch((batch) async {
|
||||
for (final entry in hashes.entries) {
|
||||
batch.update(
|
||||
_db.trashedLocalAssetEntity,
|
||||
TrashedLocalAssetEntityCompanion(checksum: Value(entry.value)),
|
||||
where: (e) => e.id.equals(entry.key),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getAssetsToHash(Iterable<String> albumIds) {
|
||||
final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull());
|
||||
return query.map((row) => row.toLocalAsset()).get();
|
||||
}
|
||||
|
||||
Future<Iterable<LocalAsset>> getToRestore() async {
|
||||
final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity)
|
||||
..addColumns([_db.localAlbumEntity.id])
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected)));
|
||||
|
||||
final rows =
|
||||
await (_db.select(_db.trashedLocalAssetEntity).join([
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum),
|
||||
),
|
||||
])..where(
|
||||
_db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull(),
|
||||
))
|
||||
.get();
|
||||
|
||||
return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toLocalAsset());
|
||||
}
|
||||
|
||||
/// Applies resulted snapshot of trashed assets:
|
||||
/// - upserts incoming rows
|
||||
/// - deletes rows that are not present in the snapshot
|
||||
Future<void> processTrashSnapshot(Iterable<TrashedAsset> trashedAssets) async {
|
||||
if (trashedAssets.isEmpty) {
|
||||
await _db.delete(_db.trashedLocalAssetEntity).go();
|
||||
return;
|
||||
}
|
||||
final assetIds = trashedAssets.map((e) => e.asset.id).toSet();
|
||||
Map<String, String> localChecksumById = await _getCachedChecksums(assetIds);
|
||||
|
||||
return _db.transaction(() async {
|
||||
await _db.batch((batch) {
|
||||
for (final item in trashedAssets) {
|
||||
final effectiveChecksum = localChecksumById[item.asset.id] ?? item.asset.checksum;
|
||||
final companion = TrashedLocalAssetEntityCompanion.insert(
|
||||
id: item.asset.id,
|
||||
albumId: item.albumId,
|
||||
checksum: Value(effectiveChecksum),
|
||||
name: item.asset.name,
|
||||
type: item.asset.type,
|
||||
createdAt: Value(item.asset.createdAt),
|
||||
updatedAt: Value(item.asset.updatedAt),
|
||||
width: Value(item.asset.width),
|
||||
height: Value(item.asset.height),
|
||||
durationInSeconds: Value(item.asset.durationInSeconds),
|
||||
isFavorite: Value(item.asset.isFavorite),
|
||||
orientation: Value(item.asset.orientation),
|
||||
);
|
||||
|
||||
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
|
||||
_db.trashedLocalAssetEntity,
|
||||
companion,
|
||||
onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(item.asset.updatedAt)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (assetIds.length <= kDriftMaxChunk) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((row) => row.id.isNotIn(assetIds))).go();
|
||||
} else {
|
||||
final existingIds = await (_db.selectOnly(
|
||||
_db.trashedLocalAssetEntity,
|
||||
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
|
||||
final idToDelete = existingIds.where((id) => !assetIds.contains(id));
|
||||
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Stream<int> watchCount() {
|
||||
return (_db.selectOnly(_db.trashedLocalAssetEntity)..addColumns([_db.trashedLocalAssetEntity.id.count()]))
|
||||
.watchSingle()
|
||||
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
||||
}
|
||||
|
||||
Stream<int> watchHashedCount() {
|
||||
return (_db.selectOnly(_db.trashedLocalAssetEntity)
|
||||
..addColumns([_db.trashedLocalAssetEntity.id.count()])
|
||||
..where(_db.trashedLocalAssetEntity.checksum.isNotNull()))
|
||||
.watchSingle()
|
||||
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
||||
}
|
||||
|
||||
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
|
||||
if (assetsByAlbums.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final companions = <TrashedLocalAssetEntityCompanion>[];
|
||||
final idToDelete = <String>{};
|
||||
|
||||
for (final entry in assetsByAlbums.entries) {
|
||||
for (final asset in entry.value) {
|
||||
idToDelete.add(asset.id);
|
||||
companions.add(
|
||||
TrashedLocalAssetEntityCompanion(
|
||||
id: Value(asset.id),
|
||||
name: Value(asset.name),
|
||||
albumId: Value(entry.key),
|
||||
checksum: Value(asset.checksum),
|
||||
type: Value(asset.type),
|
||||
width: Value(asset.width),
|
||||
height: Value(asset.height),
|
||||
durationInSeconds: Value(asset.durationInSeconds),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
orientation: Value(asset.orientation),
|
||||
createdAt: Value(asset.createdAt),
|
||||
updatedAt: Value(asset.updatedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final companion in companions) {
|
||||
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
|
||||
}
|
||||
|
||||
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> applyRestoredAssets(List<String> idList) async {
|
||||
if (idList.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final trashedAssets = <TrashedLocalAssetEntityData>[];
|
||||
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
final q = _db.select(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice));
|
||||
trashedAssets.addAll(await q.get());
|
||||
}
|
||||
|
||||
if (trashedAssets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final companions = trashedAssets.map((e) {
|
||||
return LocalAssetEntityCompanion.insert(
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
createdAt: Value(e.createdAt),
|
||||
updatedAt: Value(e.updatedAt),
|
||||
width: Value(e.width),
|
||||
height: Value(e.height),
|
||||
durationInSeconds: Value(e.durationInSeconds),
|
||||
checksum: Value(e.checksum),
|
||||
isFavorite: Value(e.isFavorite),
|
||||
orientation: Value(e.orientation),
|
||||
);
|
||||
});
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final companion in companions) {
|
||||
await _db.into(_db.localAssetEntity).insertOnConflictUpdate(companion);
|
||||
}
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, List<LocalAsset>>> getToTrash() async {
|
||||
final result = <String, List<LocalAsset>>{};
|
||||
|
||||
final rows =
|
||||
await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
||||
),
|
||||
])..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.remoteAssetEntity.deletedAt.isNotNull(),
|
||||
))
|
||||
.get();
|
||||
|
||||
for (final row in rows) {
|
||||
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
|
||||
final asset = row.readTable(_db.localAssetEntity).toDto();
|
||||
(result[albumId] ??= <LocalAsset>[]).add(asset);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
//attempt to reuse existing checksums
|
||||
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
|
||||
final localChecksumById = <String, String>{};
|
||||
|
||||
for (final slice in assetIds.slices(kDriftMaxChunk)) {
|
||||
final rows =
|
||||
await (_db.selectOnly(_db.localAssetEntity)
|
||||
..where(_db.localAssetEntity.id.isIn(slice) & _db.localAssetEntity.checksum.isNotNull())
|
||||
..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum]))
|
||||
.get();
|
||||
|
||||
for (final r in rows) {
|
||||
localChecksumById[r.read(_db.localAssetEntity.id)!] = r.read(_db.localAssetEntity.checksum)!;
|
||||
}
|
||||
}
|
||||
|
||||
return localChecksumById;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -66,7 +65,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
bool syncSuccess = false;
|
||||
await Future.wait([
|
||||
backgroundManager.syncLocal(full: Platform.isAndroid ? true : false),
|
||||
backgroundManager.syncLocal(full: true),
|
||||
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
||||
]);
|
||||
|
||||
|
||||
28
mobile/lib/platform/native_sync_api.g.dart
generated
28
mobile/lib/platform/native_sync_api.g.dart
generated
@@ -562,32 +562,4 @@ class NativeSyncApi {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$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 if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, List<PlatformAsset>>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,21 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/activities/comment_bubble.dart';
|
||||
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/activity_service.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftActivitiesPage extends HookConsumerWidget {
|
||||
@@ -19,8 +27,10 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id));
|
||||
final asset = ref.read(currentAssetNotifier) as RemoteAsset?;
|
||||
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
|
||||
final listViewScrollController = useScrollController();
|
||||
|
||||
void scrollToBottom() {
|
||||
@@ -36,7 +46,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(album.name),
|
||||
title: asset == null ? Text(album.name) : null,
|
||||
actions: [const LikeActivityActionButton(menuItem: true)],
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
@@ -47,7 +57,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
activityWidgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: CommentBubble(activity: activity),
|
||||
child: _CommentBubble(activity: activity),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -81,3 +91,139 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentBubble extends ConsumerWidget {
|
||||
final Activity activity;
|
||||
|
||||
const _CommentBubble({required this.activity});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final album = ref.watch(currentRemoteAlbumProvider)!;
|
||||
final isOwn = activity.user.id == user?.id;
|
||||
final canDelete = isOwn || album.ownerId == user?.id;
|
||||
final hasAsset = activity.assetId != null && activity.assetId!.isNotEmpty;
|
||||
final isLike = activity.type == ActivityType.like;
|
||||
final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer;
|
||||
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id, activity.assetId).notifier);
|
||||
|
||||
Future<void> openAssetViewer() async {
|
||||
final activityService = ref.read(activityServiceProvider);
|
||||
final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref);
|
||||
if (route != null) await context.pushRoute(route);
|
||||
}
|
||||
|
||||
Widget avatar() {
|
||||
if (isOwn) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return UserCircleAvatar(user: activity.user, size: 28, radius: 14);
|
||||
}
|
||||
|
||||
Widget? thumbnail() {
|
||||
if (!hasAsset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150),
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: openAssetViewer,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: Image(
|
||||
image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isLike)
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle),
|
||||
child: Icon(Icons.favorite, color: Colors.red[600], size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Likes Album widget (for likes without asset)
|
||||
Widget? likesToAlbum() {
|
||||
if (!isLike || hasAsset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle),
|
||||
child: Icon(Icons.favorite, color: Colors.red[600], size: 18),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? commentBubble() {
|
||||
if (activity.comment == null || activity.comment!.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(12))),
|
||||
child: Text(
|
||||
activity.comment ?? '',
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Combined content widgets
|
||||
final List<Widget> contentChildren = [thumbnail(), likesToAlbum(), commentBubble()].whereType<Widget>().toList();
|
||||
|
||||
return DismissibleActivity(
|
||||
onDismiss: canDelete ? (id) async => await activityNotifier.removeActivity(id) : null,
|
||||
activity.id,
|
||||
Align(
|
||||
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOwn) ...[avatar(), const SizedBox(width: 8)],
|
||||
// Content column
|
||||
Column(
|
||||
crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
...contentChildren.map((w) => Padding(padding: const EdgeInsets.only(bottom: 8.0), child: w)),
|
||||
Text(
|
||||
'${activity.user.name} • ${activity.createdAt.timeAgo()}',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isOwn) const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
@@ -55,7 +54,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
final previousFilter = useState<SearchFilter?>(null);
|
||||
final dateInputFilter = useState<DateFilterInputModel?>(null);
|
||||
|
||||
final peopleCurrentFilterWidget = useState<Widget?>(null);
|
||||
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
|
||||
@@ -247,54 +245,19 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
datePicked(DateFilterInputModel? selectedDate) {
|
||||
dateInputFilter.value = selectedDate;
|
||||
if (selectedDate == null) {
|
||||
filter.value = filter.value.copyWith(date: SearchDateFilter());
|
||||
|
||||
dateRangeCurrentFilterWidget.value = null;
|
||||
unawaited(search());
|
||||
return;
|
||||
}
|
||||
|
||||
final date = selectedDate.asDateTimeRange();
|
||||
|
||||
filter.value = filter.value.copyWith(
|
||||
date: SearchDateFilter(
|
||||
takenAfter: date.start,
|
||||
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
|
||||
),
|
||||
);
|
||||
|
||||
dateRangeCurrentFilterWidget.value = Text(
|
||||
selectedDate.asHumanReadable(context),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
|
||||
unawaited(search());
|
||||
}
|
||||
|
||||
showDatePicker() async {
|
||||
final firstDate = DateTime(1900);
|
||||
final lastDate = DateTime.now();
|
||||
|
||||
var dateRange = DateTimeRange(
|
||||
start: filter.value.date.takenAfter ?? lastDate,
|
||||
end: filter.value.date.takenBefore ?? lastDate,
|
||||
);
|
||||
|
||||
// datePicked() may increase the date, this will make the date picker fail an assertion
|
||||
// Fixup the end date to be at most now.
|
||||
if (dateRange.end.isAfter(lastDate)) {
|
||||
dateRange = DateTimeRange(start: dateRange.start, end: lastDate);
|
||||
}
|
||||
|
||||
final date = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
currentDate: DateTime.now(),
|
||||
initialDateRange: dateRange,
|
||||
initialDateRange: DateTimeRange(
|
||||
start: filter.value.date.takenAfter ?? lastDate,
|
||||
end: filter.value.date.takenBefore ?? lastDate,
|
||||
),
|
||||
helpText: 'search_filter_date_title'.t(context: context),
|
||||
cancelText: 'cancel'.t(context: context),
|
||||
confirmText: 'select'.t(context: context),
|
||||
@@ -308,32 +271,40 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
if (date == null) {
|
||||
datePicked(null);
|
||||
} else {
|
||||
datePicked(CustomDateFilter.fromRange(date));
|
||||
}
|
||||
}
|
||||
filter.value = filter.value.copyWith(date: SearchDateFilter());
|
||||
|
||||
showQuickDatePicker() {
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: "pick_date_range".tr(),
|
||||
expanded: true,
|
||||
onClear: () => datePicked(null),
|
||||
child: QuickDatePicker(
|
||||
currentInput: dateInputFilter.value,
|
||||
onRequestPicker: () {
|
||||
context.pop();
|
||||
showDatePicker();
|
||||
},
|
||||
onSelect: (date) {
|
||||
context.pop();
|
||||
datePicked(date);
|
||||
},
|
||||
),
|
||||
dateRangeCurrentFilterWidget.value = null;
|
||||
unawaited(search());
|
||||
return;
|
||||
}
|
||||
|
||||
filter.value = filter.value.copyWith(
|
||||
date: SearchDateFilter(
|
||||
takenAfter: date.start,
|
||||
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
|
||||
),
|
||||
);
|
||||
|
||||
// If date range is less than 24 hours, set the end date to the end of the day
|
||||
if (date.end.difference(date.start).inHours < 24) {
|
||||
dateRangeCurrentFilterWidget.value = Text(
|
||||
DateFormat.yMMMd().format(date.start.toLocal()),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
} else {
|
||||
dateRangeCurrentFilterWidget.value = Text(
|
||||
'search_filter_date_interval'.t(
|
||||
context: context,
|
||||
args: {
|
||||
"start": DateFormat.yMMMd().format(date.start.toLocal()),
|
||||
"end": DateFormat.yMMMd().format(date.end.toLocal()),
|
||||
},
|
||||
),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
unawaited(search());
|
||||
}
|
||||
|
||||
// MEDIA PICKER
|
||||
@@ -618,7 +589,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.date_range_outlined,
|
||||
onTap: showQuickDatePicker,
|
||||
onTap: showDatePicker,
|
||||
label: 'search_filter_date'.t(context: context),
|
||||
currentFilter: dateRangeCurrentFilterWidget.value,
|
||||
),
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/activities/comment_bubble.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
|
||||
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
|
||||
|
||||
class ActivitiesBottomSheet extends HookConsumerWidget {
|
||||
final DraggableScrollableController controller;
|
||||
@@ -26,6 +28,7 @@ class ActivitiesBottomSheet extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentRemoteAlbumProvider)!;
|
||||
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
|
||||
@@ -44,9 +47,16 @@ class ActivitiesBottomSheet extends HookConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final activity = data[data.length - 1 - index];
|
||||
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: CommentBubble(activity: activity, isAssetActivity: true),
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: DismissibleActivity(
|
||||
activity.id,
|
||||
ActivityTile(activity, isBottomSheet: true),
|
||||
onDismiss: canDelete
|
||||
? (activityId) async => await activityNotifier.removeActivity(activity.id)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}, childCount: data.length + 1),
|
||||
);
|
||||
|
||||
@@ -627,10 +627,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
// Rebuild the widget when the asset viewer state changes
|
||||
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
|
||||
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
ref.watch(isPlayingMotionVideoProvider);
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
// Listen for casting changes and send initial asset to the cast provider
|
||||
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async {
|
||||
@@ -663,14 +663,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: IgnorePointer(
|
||||
ignoring: !showingControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showingControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
),
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
|
||||
@@ -127,18 +127,13 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
if (exifInfo == null) {
|
||||
return null;
|
||||
}
|
||||
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
||||
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
||||
return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
|
||||
String? _getLensInfoSubtitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) {
|
||||
return null;
|
||||
}
|
||||
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
||||
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
||||
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
||||
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
||||
|
||||
return [fNumber, exposureTime, focalLength, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
|
||||
Future<void> _editDateTime(BuildContext context, WidgetRef ref) async {
|
||||
@@ -146,20 +141,20 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildAppearsInList(WidgetRef ref, BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
final aseet = ref.watch(currentAssetNotifier);
|
||||
if (aseet == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!asset.hasRemote) {
|
||||
if (!aseet.hasRemote) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
String? remoteAssetId;
|
||||
if (asset is RemoteAsset) {
|
||||
remoteAssetId = asset.id;
|
||||
} else if (asset is LocalAsset) {
|
||||
remoteAssetId = asset.remoteAssetId;
|
||||
if (aseet is RemoteAsset) {
|
||||
remoteAssetId = aseet.id;
|
||||
} else if (aseet is LocalAsset) {
|
||||
remoteAssetId = aseet.remoteAssetId;
|
||||
}
|
||||
|
||||
if (remoteAssetId == null) {
|
||||
@@ -222,7 +217,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
|
||||
// Build file info tile based on asset type
|
||||
@@ -293,19 +287,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
_SheetTile(
|
||||
title: cameraTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getCameraInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
|
||||
),
|
||||
),
|
||||
// Lens info
|
||||
if (lensTitle != null)
|
||||
_SheetTile(
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getLensInfoSubtitle(exifInfo),
|
||||
subtitle: _getCameraInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
|
||||
),
|
||||
|
||||
@@ -86,9 +86,13 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: widget.actions),
|
||||
SizedBox(
|
||||
height: 115,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: widget.actions,
|
||||
),
|
||||
),
|
||||
const Divider(indent: 16, endIndent: 16),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
sealed class DateFilterInputModel {
|
||||
DateTimeRange<DateTime> asDateTimeRange();
|
||||
|
||||
String asHumanReadable(BuildContext context) {
|
||||
// General implementation for arbitrary date and time ranges
|
||||
// If date range is less than 24 hours, set the end date to the end of the day
|
||||
final date = asDateTimeRange();
|
||||
if (date.end.difference(date.start).inHours < 24) {
|
||||
return DateFormat.yMMMd().format(date.start.toLocal());
|
||||
} else {
|
||||
return 'search_filter_date_interval'.t(
|
||||
context: context,
|
||||
args: {
|
||||
"start": DateFormat.yMMMd().format(date.start.toLocal()),
|
||||
"end": DateFormat.yMMMd().format(date.end.toLocal()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RecentMonthRangeFilter extends DateFilterInputModel {
|
||||
final int monthDelta;
|
||||
RecentMonthRangeFilter(this.monthDelta);
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime> asDateTimeRange() {
|
||||
final now = DateTime.now();
|
||||
// Note that DateTime's constructor properly handles month overflow.
|
||||
final from = DateTime(now.year, now.month - monthDelta, 1);
|
||||
return DateTimeRange<DateTime>(start: from, end: now);
|
||||
}
|
||||
|
||||
@override
|
||||
String asHumanReadable(BuildContext context) {
|
||||
return 'last_months'.t(context: context, args: {"count": monthDelta.toString()});
|
||||
}
|
||||
}
|
||||
|
||||
class YearFilter extends DateFilterInputModel {
|
||||
final int year;
|
||||
YearFilter(this.year);
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime> asDateTimeRange() {
|
||||
final now = DateTime.now();
|
||||
final from = DateTime(year, 1, 1);
|
||||
|
||||
if (now.year == year) {
|
||||
// To not go beyond today if the user picks the current year
|
||||
return DateTimeRange<DateTime>(start: from, end: now);
|
||||
}
|
||||
|
||||
final to = DateTime(year, 12, 31, 23, 59, 59);
|
||||
return DateTimeRange<DateTime>(start: from, end: to);
|
||||
}
|
||||
|
||||
@override
|
||||
String asHumanReadable(BuildContext context) {
|
||||
return 'in_year'.tr(namedArgs: {"year": year.toString()});
|
||||
}
|
||||
}
|
||||
|
||||
class CustomDateFilter extends DateFilterInputModel {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
|
||||
CustomDateFilter(this.start, this.end);
|
||||
|
||||
factory CustomDateFilter.fromRange(DateTimeRange<DateTime> range) {
|
||||
return CustomDateFilter(range.start, range.end);
|
||||
}
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime> asDateTimeRange() {
|
||||
return DateTimeRange<DateTime>(start: start, end: end);
|
||||
}
|
||||
}
|
||||
|
||||
enum _QuickPickerType { last1Month, last3Months, last9Months, year, custom }
|
||||
|
||||
class QuickDatePicker extends HookWidget {
|
||||
QuickDatePicker({super.key, required this.currentInput, required this.onSelect, required this.onRequestPicker})
|
||||
: _selection = _selectionFromModel(currentInput),
|
||||
_initialYear = _initialYearFromModel(currentInput);
|
||||
|
||||
final Function() onRequestPicker;
|
||||
final Function(DateFilterInputModel range) onSelect;
|
||||
|
||||
final DateFilterInputModel? currentInput;
|
||||
final _QuickPickerType? _selection;
|
||||
final int _initialYear;
|
||||
|
||||
// Generate a list of recent years from 2000 to the current year (including the current one)
|
||||
final List<int> _recentYears = List.generate(1 + DateTime.now().year - 2000, (index) {
|
||||
return index + 2000;
|
||||
});
|
||||
|
||||
static int _initialYearFromModel(DateFilterInputModel? model) {
|
||||
return model?.asDateTimeRange().start.year ?? DateTime.now().year;
|
||||
}
|
||||
|
||||
static _QuickPickerType? _selectionFromModel(DateFilterInputModel? model) {
|
||||
if (model is RecentMonthRangeFilter) {
|
||||
return switch (model.monthDelta) {
|
||||
1 => _QuickPickerType.last1Month,
|
||||
3 => _QuickPickerType.last3Months,
|
||||
9 => _QuickPickerType.last9Months,
|
||||
_ => _QuickPickerType.custom,
|
||||
};
|
||||
} else if (model is YearFilter) {
|
||||
return _QuickPickerType.year;
|
||||
} else if (model is CustomDateFilter) {
|
||||
return _QuickPickerType.custom;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Text _monthLabel(BuildContext context, int monthsFromNow) =>
|
||||
const Text('last_months').t(context: context, args: {"count": monthsFromNow.toString()});
|
||||
|
||||
Widget _yearPicker(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
return Row(
|
||||
children: [
|
||||
const Text("in_year_selector").tr(),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: DropdownMenu(
|
||||
initialSelection: _initialYear,
|
||||
menuStyle: MenuStyle(maximumSize: WidgetStateProperty.all(Size(size.width, size.height * 0.5))),
|
||||
dropdownMenuEntries: _recentYears.map((e) => DropdownMenuEntry(value: e, label: e.toString())).toList(),
|
||||
onSelected: (year) {
|
||||
if (year == null) return;
|
||||
onSelect(YearFilter(year));
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// We want the exact date picker to always be selectable.
|
||||
// Even if it's already toggled it should always open the full date picker, RadioListTiles don't do that by default
|
||||
// so we wrap it in a InkWell
|
||||
Widget _exactPicker(BuildContext context) {
|
||||
final hasPreviousInput = currentInput != null && currentInput is CustomDateFilter;
|
||||
|
||||
return InkWell(
|
||||
onTap: onRequestPicker,
|
||||
child: IgnorePointer(
|
||||
ignoring: true,
|
||||
child: RadioListTile(
|
||||
title: const Text('pick_custom_range').tr(),
|
||||
subtitle: hasPreviousInput ? Text(currentInput!.asHumanReadable(context)) : null,
|
||||
secondary: hasPreviousInput ? const Icon(Icons.edit) : null,
|
||||
value: _QuickPickerType.custom,
|
||||
toggleable: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Scrollbar(
|
||||
// Depending on the screen size the last option might get cut off
|
||||
// Add a clear visual cue that there are more options when scrolling
|
||||
// When the screen size is large enough the scrollbar is hidden automatically
|
||||
trackVisibility: true,
|
||||
thumbVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
child: RadioGroup(
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
final _ = switch (value) {
|
||||
_QuickPickerType.custom => onRequestPicker(),
|
||||
_QuickPickerType.last1Month => onSelect(RecentMonthRangeFilter(1)),
|
||||
_QuickPickerType.last3Months => onSelect(RecentMonthRangeFilter(3)),
|
||||
_QuickPickerType.last9Months => onSelect(RecentMonthRangeFilter(9)),
|
||||
// When a year is selected the combobox triggers onSelect() on its own.
|
||||
// Here we handle the radio button being selected which can only ever be the initial year
|
||||
_QuickPickerType.year => onSelect(YearFilter(_initialYear)),
|
||||
};
|
||||
},
|
||||
groupValue: _selection,
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile(title: _monthLabel(context, 1), value: _QuickPickerType.last1Month, toggleable: true),
|
||||
RadioListTile(title: _monthLabel(context, 3), value: _QuickPickerType.last3Months, toggleable: true),
|
||||
RadioListTile(title: _monthLabel(context, 9), value: _QuickPickerType.last9Months, toggleable: true),
|
||||
RadioListTile(title: _yearPicker(context), value: _QuickPickerType.year, toggleable: true),
|
||||
_exactPicker(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/providers/activity_service.provider.dart';
|
||||
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
||||
@@ -17,20 +16,13 @@ class AlbumActivity extends _$AlbumActivity {
|
||||
|
||||
Future<void> removeActivity(String id) async {
|
||||
if (await ref.watch(activityServiceProvider).removeActivity(id)) {
|
||||
final removedActivity = _removeFromState(id);
|
||||
if (removedActivity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetId != null) {
|
||||
ref.read(albumActivityProvider(albumId).notifier)._removeFromState(id);
|
||||
}
|
||||
|
||||
final activities = state.valueOrNull ?? [];
|
||||
final removedActivity = activities.firstWhere((a) => a.id == id);
|
||||
activities.remove(removedActivity);
|
||||
state = AsyncData(activities);
|
||||
// Decrement activity count only for comments
|
||||
if (removedActivity.type == ActivityType.comment) {
|
||||
ref.watch(activityStatisticsProvider(albumId, assetId).notifier).removeActivity();
|
||||
if (assetId != null) {
|
||||
ref.watch(activityStatisticsProvider(albumId).notifier).removeActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,10 +30,8 @@ class AlbumActivity extends _$AlbumActivity {
|
||||
Future<void> addLike() async {
|
||||
final activity = await ref.watch(activityServiceProvider).addActivity(albumId, ActivityType.like, assetId: assetId);
|
||||
if (activity.hasValue) {
|
||||
_addToState(activity.requireValue);
|
||||
if (assetId != null) {
|
||||
ref.read(albumActivityProvider(albumId).notifier)._addToState(activity.requireValue);
|
||||
}
|
||||
final activities = state.asData?.value ?? [];
|
||||
state = AsyncData([...activities, activity.requireValue]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +41,8 @@ class AlbumActivity extends _$AlbumActivity {
|
||||
.addActivity(albumId, ActivityType.comment, assetId: assetId, comment: comment);
|
||||
|
||||
if (activity.hasValue) {
|
||||
_addToState(activity.requireValue);
|
||||
if (assetId != null) {
|
||||
ref.read(albumActivityProvider(albumId).notifier)._addToState(activity.requireValue);
|
||||
}
|
||||
final activities = state.valueOrNull ?? [];
|
||||
state = AsyncData([...activities, activity.requireValue]);
|
||||
ref.watch(activityStatisticsProvider(albumId, assetId).notifier).addActivity();
|
||||
// The previous addActivity call would increase the count of an asset if assetId != null
|
||||
// To also increase the activity count of the album, calling it once again with assetId set to null
|
||||
@@ -63,29 +51,6 @@ class AlbumActivity extends _$AlbumActivity {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _addToState(Activity activity) {
|
||||
final activities = state.valueOrNull ?? [];
|
||||
if (activities.any((a) => a.id == activity.id)) {
|
||||
return;
|
||||
}
|
||||
state = AsyncData([...activities, activity]);
|
||||
}
|
||||
|
||||
Activity? _removeFromState(String id) {
|
||||
final activities = state.valueOrNull;
|
||||
if (activities == null) {
|
||||
return null;
|
||||
}
|
||||
final activity = activities.firstWhereOrNull((a) => a.id == id);
|
||||
if (activity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final updated = [...activities]..remove(activity);
|
||||
state = AsyncData(updated);
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock class for testing
|
||||
|
||||
2
mobile/lib/providers/activity.provider.g.dart
generated
2
mobile/lib/providers/activity.provider.g.dart
generated
@@ -6,7 +6,7 @@ part of 'activity.provider.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$albumActivityHash() => r'154e8ae98da3efc142369eae46d4005468fd67da';
|
||||
String _$albumActivityHash() => r'3b0d7acee4d41c84b3f220784c3b904c83f836e6';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@@ -14,10 +13,6 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
||||
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||
|
||||
@@ -10,17 +10,11 @@ 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';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
|
||||
final syncStreamServiceProvider = Provider(
|
||||
(ref) => SyncStreamService(
|
||||
syncApiRepository: ref.watch(syncApiRepositoryProvider),
|
||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
cancelChecker: ref.watch(cancellationProvider),
|
||||
),
|
||||
);
|
||||
@@ -32,9 +26,6 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref.
|
||||
final localSyncServiceProvider = Provider(
|
||||
(ref) => LocalSyncService(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
),
|
||||
);
|
||||
@@ -44,6 +35,5 @@ final hashServiceProvider = Provider(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
|
||||
typedef TrashedAssetsCount = ({int total, int hashed});
|
||||
|
||||
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
|
||||
final repo = ref.watch(trashedLocalAssetRepository);
|
||||
final total$ = repo.watchCount();
|
||||
final hashed$ = repo.watchHashedCount();
|
||||
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/services/trash.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TrashNotifier extends StateNotifier<bool> {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/services/local_files_manager.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final localFilesManagerRepositoryProvider = Provider(
|
||||
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
|
||||
);
|
||||
|
||||
class LocalFilesManagerRepository {
|
||||
LocalFilesManagerRepository(this._service);
|
||||
const LocalFilesManagerRepository(this._service);
|
||||
|
||||
final Logger _logger = Logger('SyncStreamService');
|
||||
final LocalFilesManagerService _service;
|
||||
|
||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
@@ -24,26 +21,4 @@ class LocalFilesManagerRepository {
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
return await _service.requestManageMediaPermission();
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
return await _service.hasManageMediaPermission();
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
return await _service.manageMediaPermission();
|
||||
}
|
||||
|
||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||
final restoredIds = <String>[];
|
||||
for (final asset in assets) {
|
||||
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
|
||||
try {
|
||||
await _service.restoreFromTrashById(asset.id, asset.type.index);
|
||||
restoredIds.add(asset.id);
|
||||
} catch (e) {
|
||||
_logger.warning("Restoring failure: $e");
|
||||
}
|
||||
}
|
||||
return restoredIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,7 +313,6 @@ class AppRouter extends RootStackRouter {
|
||||
settings: page,
|
||||
pageBuilder: (_, __, ___) => child,
|
||||
opaque: false,
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -77,7 +77,6 @@ class DeepLinkService {
|
||||
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
||||
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
|
||||
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
||||
"activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -186,18 +185,4 @@ class DeepLinkService {
|
||||
return AlbumViewerRoute(albumId: album.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildActivityDeepLink(String albumId) async {
|
||||
if (Store.isBetaTimelineEnabled == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final album = await _betaRemoteAlbumService.get(albumId);
|
||||
|
||||
if (album == null || album.isActivityEnabled == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DriftActivitiesRoute(album: album);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref)
|
||||
|
||||
class LocalFilesManagerService {
|
||||
const LocalFilesManagerService();
|
||||
|
||||
static final Logger _logger = Logger('LocalFilesManager');
|
||||
static const MethodChannel _channel = MethodChannel('file_trash');
|
||||
|
||||
@@ -28,15 +27,6 @@ class LocalFilesManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||
try {
|
||||
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error restore file from trash by Id', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('requestManageMediaPermission');
|
||||
@@ -45,22 +35,4 @@ class LocalFilesManagerService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('hasManageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission state', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('manageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission settings', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,6 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
||||
addDefault(value, 'ocr', false);
|
||||
}
|
||||
break;
|
||||
case 'MemoriesResponse':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'duration', 5);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/activity_service.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||
|
||||
class CommentBubble extends ConsumerWidget {
|
||||
final Activity activity;
|
||||
final bool isAssetActivity;
|
||||
|
||||
const CommentBubble({super.key, required this.activity, this.isAssetActivity = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final album = ref.watch(currentRemoteAlbumProvider)!;
|
||||
final isOwn = activity.user.id == user?.id;
|
||||
final canDelete = isOwn || album.ownerId == user?.id;
|
||||
final showThumbnail = !isAssetActivity && activity.assetId != null && activity.assetId!.isNotEmpty;
|
||||
final isLike = activity.type == ActivityType.like;
|
||||
final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer;
|
||||
|
||||
final activityNotifier = ref.read(
|
||||
albumActivityProvider(album.id, isAssetActivity ? activity.assetId : null).notifier,
|
||||
);
|
||||
|
||||
Future<void> openAssetViewer() async {
|
||||
final activityService = ref.read(activityServiceProvider);
|
||||
final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref);
|
||||
if (route != null) await context.pushRoute(route);
|
||||
}
|
||||
|
||||
// avatar (hidden for own messages)
|
||||
Widget avatar = const SizedBox.shrink();
|
||||
if (!isOwn) {
|
||||
avatar = UserCircleAvatar(user: activity.user, size: 28, radius: 14);
|
||||
}
|
||||
|
||||
// Thumbnail with tappable behavior and optional heart overlay
|
||||
Widget? thumbnail;
|
||||
if (showThumbnail) {
|
||||
thumbnail = ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150),
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: openAssetViewer,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: Image(
|
||||
image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isLike)
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle),
|
||||
child: Icon(Icons.favorite, color: Colors.red[600], size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Likes widget
|
||||
Widget? likes;
|
||||
if (isLike && !showThumbnail) {
|
||||
likes = Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle),
|
||||
child: Icon(Icons.favorite, color: Colors.red[600], size: 18),
|
||||
);
|
||||
}
|
||||
|
||||
// Comment bubble, comment-only
|
||||
Widget? commentBubble;
|
||||
if (activity.comment != null && activity.comment!.isNotEmpty) {
|
||||
commentBubble = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(12))),
|
||||
child: Text(
|
||||
activity.comment ?? '',
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Combined content widgets
|
||||
final List<Widget> contentChildren = [thumbnail, likes, commentBubble].whereType<Widget>().toList();
|
||||
|
||||
return DismissibleActivity(
|
||||
onDismiss: canDelete ? (id) async => await activityNotifier.removeActivity(id) : null,
|
||||
activity.id,
|
||||
Align(
|
||||
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOwn) ...[avatar, const SizedBox(width: 8)],
|
||||
// Content column
|
||||
Column(
|
||||
crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
...contentChildren.map((w) => Padding(padding: const EdgeInsets.only(bottom: 8.0), child: w)),
|
||||
Text(
|
||||
'${activity.user.name} • ${activity.createdAt.timeAgo()}',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isOwn) const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
@@ -178,55 +177,6 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
getManageMediaPermission() async {
|
||||
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
'manage_media_access_title',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
|
||||
).tr(),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
|
||||
const SizedBox(height: 4),
|
||||
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'cancel'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'manage_media_access_settings'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
|
||||
|
||||
login() async {
|
||||
TextInput.finishAutofillContext();
|
||||
|
||||
@@ -244,9 +194,6 @@ class LoginForm extends HookConsumerWidget {
|
||||
final isBeta = Store.isBetaTimelineEnabled;
|
||||
if (isBeta) {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
if (isSyncRemoteDeletionsMode()) {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
@@ -346,9 +293,6 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
if (isBeta) {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
if (isSyncRemoteDeletionsMode()) {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,7 @@ class FilterBottomSheetScaffold extends StatelessWidget {
|
||||
const FilterBottomSheetScaffold({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onSearch,
|
||||
required this.onSearch,
|
||||
required this.onClear,
|
||||
required this.title,
|
||||
this.expanded,
|
||||
@@ -15,7 +15,7 @@ class FilterBottomSheetScaffold extends StatelessWidget {
|
||||
final bool? expanded;
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Function()? onSearch;
|
||||
final Function() onSearch;
|
||||
final Function() onClear;
|
||||
|
||||
@override
|
||||
@@ -48,16 +48,15 @@ class FilterBottomSheetScaffold extends StatelessWidget {
|
||||
},
|
||||
child: const Text('clear').tr(),
|
||||
),
|
||||
if (onSearch != null) const SizedBox(width: 8),
|
||||
if (onSearch != null)
|
||||
ElevatedButton(
|
||||
key: const Key('search_filter_apply'),
|
||||
onPressed: () {
|
||||
onSearch!();
|
||||
context.pop();
|
||||
},
|
||||
child: const Text('search_filter_apply').tr(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
key: const Key('search_filter_apply'),
|
||||
onPressed: () {
|
||||
onSearch();
|
||||
context.pop();
|
||||
},
|
||||
child: const Text('search_filter_apply').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
@@ -17,7 +17,6 @@ import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
|
||||
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
@@ -26,15 +25,12 @@ import 'package:logging/logging.dart';
|
||||
|
||||
class AdvancedSettings extends HookConsumerWidget {
|
||||
const AdvancedSettings({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
bool isLoggedIn = ref.read(currentUserProvider) != null;
|
||||
|
||||
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
||||
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||
final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
|
||||
@@ -55,18 +51,6 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
return false;
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
() async {
|
||||
isManageMediaSupported.value = await checkAndroidVersion();
|
||||
if (isManageMediaSupported.value) {
|
||||
manageMediaAndroidPermission.value = await ref
|
||||
.read(localFilesManagerRepositoryProvider)
|
||||
.hasManageMediaPermission();
|
||||
}
|
||||
}();
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
final advancedSettings = [
|
||||
SettingsSwitchListTile(
|
||||
enabled: true,
|
||||
@@ -74,10 +58,11 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
title: "advanced_settings_troubleshooting_title".tr(),
|
||||
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
|
||||
),
|
||||
if (isManageMediaSupported.value)
|
||||
Column(
|
||||
children: [
|
||||
SettingsSwitchListTile(
|
||||
FutureBuilder<bool>(
|
||||
future: checkAndroidVersion(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data == true) {
|
||||
return SettingsSwitchListTile(
|
||||
enabled: true,
|
||||
valueNotifier: manageLocalMediaAndroid,
|
||||
title: "advanced_settings_sync_remote_deletions_title".tr(),
|
||||
@@ -86,24 +71,14 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
if (value) {
|
||||
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
manageLocalMediaAndroid.value = result;
|
||||
manageMediaAndroidPermission.value = result;
|
||||
}
|
||||
},
|
||||
),
|
||||
SettingsActionTile(
|
||||
title: "manage_media_access_title".tr(),
|
||||
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
|
||||
subtitle: "manage_media_access_rationale".tr(),
|
||||
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
|
||||
? const Color.fromARGB(255, 243, 188, 106)
|
||||
: null,
|
||||
onActionTap: () async {
|
||||
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
|
||||
manageMediaAndroidPermission.value = result;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
SettingsSliderListTile(
|
||||
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
|
||||
valueNotifier: levelId,
|
||||
|
||||
@@ -3,18 +3,14 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -233,7 +229,6 @@ class _SyncStatsCounts extends ConsumerWidget {
|
||||
final localAlbumService = ref.watch(localAlbumServiceProvider);
|
||||
final remoteAlbumService = ref.watch(remoteAlbumServiceProvider);
|
||||
final memoryService = ref.watch(driftMemoryServiceProvider);
|
||||
final appSettingsService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
Future<List<dynamic>> loadCounts() async {
|
||||
final assetCounts = assetService.getAssetCounts();
|
||||
@@ -356,44 +351,6 @@ class _SyncStatsCounts extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// To be removed once the experimental feature is stable
|
||||
if (CurrentPlatform.isAndroid &&
|
||||
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
|
||||
_SectionHeaderText(text: "trash".t(context: context)),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final counts = ref.watch(trashedAssetsCountProvider);
|
||||
return counts.when(
|
||||
data: (c) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Flex(
|
||||
direction: Axis.horizontal,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
Expanded(
|
||||
child: EntitiyCountTile(
|
||||
label: "local".t(context: context),
|
||||
count: c.total,
|
||||
icon: Icons.delete_outline,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: EntitiyCountTile(
|
||||
label: "hashed_assets".t(context: context),
|
||||
count: c.hashed,
|
||||
icon: Icons.tag,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, st) => Text('Error: $e'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
|
||||
class SettingsActionTile extends StatelessWidget {
|
||||
const SettingsActionTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onActionTap,
|
||||
this.statusText,
|
||||
this.statusColor,
|
||||
this.contentPadding,
|
||||
this.titleStyle,
|
||||
this.subtitleStyle,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String? statusText;
|
||||
final Color? statusColor;
|
||||
final VoidCallback onActionTap;
|
||||
final EdgeInsets? contentPadding;
|
||||
final TextStyle? titleStyle;
|
||||
final TextStyle? subtitleStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
onTap: onActionTap,
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: titleStyle ?? theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
),
|
||||
if (statusText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Chip(
|
||||
label: Text(
|
||||
statusText!,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: statusColor ?? theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
side: BorderSide(color: statusColor ?? theme.colorScheme.outlineVariant),
|
||||
shape: StadiumBorder(side: BorderSide(color: statusColor ?? theme.colorScheme.outlineVariant)),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, right: 18.0),
|
||||
child: Text(
|
||||
subtitle,
|
||||
style: subtitleStyle ?? theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
),
|
||||
trailing: Icon(Icons.arrow_forward_ios, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||
contentPadding: contentPadding ?? const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
185
mobile/mise.toml
185
mobile/mise.toml
@@ -1,185 +0,0 @@
|
||||
[tools]
|
||||
flutter = "3.35.7"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
bin = "dcm"
|
||||
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
||||
|
||||
[tasks."codegen:dart"]
|
||||
alias = "codegen"
|
||||
description = "Execute build_runner to auto-generate dart code"
|
||||
sources = [
|
||||
"pubspec.yaml",
|
||||
"build.yaml",
|
||||
"lib/**/*.dart",
|
||||
"infrastructure/**/*.drift",
|
||||
]
|
||||
outputs = { auto = true }
|
||||
run = "dart run build_runner build --delete-conflicting-outputs"
|
||||
|
||||
[tasks."codegen:pigeon"]
|
||||
alias = "pigeon"
|
||||
description = "Generate pigeon platform code"
|
||||
depends = [
|
||||
"pigeon:native-sync",
|
||||
"pigeon:thumbnail",
|
||||
"pigeon:background-worker",
|
||||
"pigeon:background-worker-lock",
|
||||
"pigeon:connectivity",
|
||||
]
|
||||
|
||||
[tasks."codegen:translation"]
|
||||
alias = "translation"
|
||||
description = "Generate translations from i18n JSONs"
|
||||
run = [
|
||||
{ task = "//i18n:format-fix" },
|
||||
{ tasks = [
|
||||
"i18n:loader",
|
||||
"i18n:keys",
|
||||
] },
|
||||
]
|
||||
|
||||
[tasks."codegen:app-icon"]
|
||||
description = "Generate app icons"
|
||||
run = "flutter pub run flutter_launcher_icons:main"
|
||||
|
||||
[tasks."codegen:splash"]
|
||||
description = "Generate splash screen"
|
||||
run = "flutter pub run flutter_native_splash:create"
|
||||
|
||||
[tasks.test]
|
||||
description = "Run mobile tests"
|
||||
run = "flutter test"
|
||||
|
||||
[tasks.lint]
|
||||
description = "Analyze Dart code"
|
||||
depends = ["analyze:dart", "analyze:dcm"]
|
||||
|
||||
[tasks."lint-fix"]
|
||||
description = "Auto-fix Dart code"
|
||||
depends = ["analyze:fix:dart", "analyze:fix:dcm"]
|
||||
|
||||
[tasks.format]
|
||||
description = "Format Dart code"
|
||||
run = "dart format --set-exit-if-changed $(find lib -name '*.dart' -not \\( -name '*.g.dart' -o -name '*.drift.dart' -o -name '*.gr.dart' \\))"
|
||||
|
||||
[tasks."build:android"]
|
||||
description = "Build Android release"
|
||||
run = "flutter build appbundle"
|
||||
|
||||
[tasks."drift:migration"]
|
||||
alias = "migration"
|
||||
description = "Generate database migrations"
|
||||
run = "dart run drift_dev make-migrations"
|
||||
|
||||
|
||||
# Internal tasks
|
||||
[tasks."pigeon:native-sync"]
|
||||
description = "Generate native sync API pigeon code"
|
||||
hide = true
|
||||
sources = ["pigeon/native_sync_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/native_sync_api.g.dart",
|
||||
"ios/Runner/Sync/Messages.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/native_sync_api.dart",
|
||||
"dart format lib/platform/native_sync_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."pigeon:thumbnail"]
|
||||
description = "Generate thumbnail API pigeon code"
|
||||
hide = true
|
||||
sources = ["pigeon/thumbnail_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/thumbnail_api.g.dart",
|
||||
"ios/Runner/Images/Thumbnails.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/thumbnail_api.dart",
|
||||
"dart format lib/platform/thumbnail_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."pigeon:background-worker"]
|
||||
description = "Generate background worker API pigeon code"
|
||||
hide = true
|
||||
sources = ["pigeon/background_worker_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/background_worker_api.g.dart",
|
||||
"ios/Runner/Background/BackgroundWorker.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/background_worker_api.dart",
|
||||
"dart format lib/platform/background_worker_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."pigeon:background-worker-lock"]
|
||||
description = "Generate background worker lock API pigeon code"
|
||||
hide = true
|
||||
sources = ["pigeon/background_worker_lock_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/background_worker_lock_api.g.dart",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
||||
"dart format lib/platform/background_worker_lock_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."pigeon:connectivity"]
|
||||
description = "Generate connectivity API pigeon code"
|
||||
hide = true
|
||||
sources = ["pigeon/connectivity_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/connectivity_api.g.dart",
|
||||
"ios/Runner/Connectivity/Connectivity.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
||||
"dart format lib/platform/connectivity_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."i18n:loader"]
|
||||
description = "Generate i18n loader"
|
||||
hide = true
|
||||
sources = ["i18n/"]
|
||||
outputs = "lib/generated/codegen_loader.g.dart"
|
||||
run = [
|
||||
"dart run easy_localization:generate -S ../i18n",
|
||||
"dart format lib/generated/codegen_loader.g.dart",
|
||||
]
|
||||
|
||||
[tasks."i18n:keys"]
|
||||
description = "Generate i18n keys"
|
||||
hide = true
|
||||
sources = ["i18n/en.json"]
|
||||
outputs = "lib/generated/intl_keys.g.dart"
|
||||
run = [
|
||||
"dart run bin/generate_keys.dart",
|
||||
"dart format lib/generated/intl_keys.g.dart",
|
||||
]
|
||||
|
||||
[tasks."analyze:dart"]
|
||||
description = "Run Dart analysis"
|
||||
hide = true
|
||||
run = "dart analyze --fatal-infos"
|
||||
|
||||
[tasks."analyze:dcm"]
|
||||
description = "Run Dart Code Metrics"
|
||||
hide = true
|
||||
run = "dcm analyze lib --fatal-style --fatal-warnings"
|
||||
|
||||
[tasks."analyze:fix:dart"]
|
||||
description = "Auto-fix Dart analysis"
|
||||
hide = true
|
||||
run = "dart fix --apply"
|
||||
|
||||
[tasks."analyze:fix:dcm"]
|
||||
description = "Auto-fix Dart Code Metrics"
|
||||
hide = true
|
||||
run = "dcm fix lib"
|
||||
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
@@ -407,7 +407,6 @@ Class | Method | HTTP request | Description
|
||||
- [MemoriesUpdate](doc//MemoriesUpdate.md)
|
||||
- [MemoryCreateDto](doc//MemoryCreateDto.md)
|
||||
- [MemoryResponseDto](doc//MemoryResponseDto.md)
|
||||
- [MemorySearchOrder](doc//MemorySearchOrder.md)
|
||||
- [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md)
|
||||
- [MemoryType](doc//MemoryType.md)
|
||||
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
|
||||
|
||||
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@@ -175,7 +175,6 @@ part 'model/memories_response.dart';
|
||||
part 'model/memories_update.dart';
|
||||
part 'model/memory_create_dto.dart';
|
||||
part 'model/memory_response_dto.dart';
|
||||
part 'model/memory_search_order.dart';
|
||||
part 'model/memory_statistics_response_dto.dart';
|
||||
part 'model/memory_type.dart';
|
||||
part 'model/memory_update_dto.dart';
|
||||
|
||||
44
mobile/openapi/lib/api/memories_api.dart
generated
44
mobile/openapi/lib/api/memories_api.dart
generated
@@ -238,13 +238,8 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [bool] isTrashed:
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
|
||||
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/memories/statistics';
|
||||
|
||||
@@ -264,12 +259,6 @@ class MemoriesApi {
|
||||
if (isTrashed != null) {
|
||||
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
|
||||
}
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
if (type != null) {
|
||||
queryParams.addAll(_queryParams('', 'type', type));
|
||||
}
|
||||
@@ -298,14 +287,9 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [bool] isTrashed:
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
|
||||
final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, );
|
||||
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async {
|
||||
final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, type: type, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -391,13 +375,8 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [bool] isTrashed:
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
|
||||
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/memories';
|
||||
|
||||
@@ -417,12 +396,6 @@ class MemoriesApi {
|
||||
if (isTrashed != null) {
|
||||
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
|
||||
}
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
if (type != null) {
|
||||
queryParams.addAll(_queryParams('', 'type', type));
|
||||
}
|
||||
@@ -451,14 +424,9 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [bool] isTrashed:
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
|
||||
final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, );
|
||||
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async {
|
||||
final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, type: type, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
@@ -404,8 +404,6 @@ class ApiClient {
|
||||
return MemoryCreateDto.fromJson(value);
|
||||
case 'MemoryResponseDto':
|
||||
return MemoryResponseDto.fromJson(value);
|
||||
case 'MemorySearchOrder':
|
||||
return MemorySearchOrderTypeTransformer().decode(value);
|
||||
case 'MemoryStatisticsResponseDto':
|
||||
return MemoryStatisticsResponseDto.fromJson(value);
|
||||
case 'MemoryType':
|
||||
|
||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@@ -106,9 +106,6 @@ String parameterToString(dynamic value) {
|
||||
if (value is ManualJobName) {
|
||||
return ManualJobNameTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is MemorySearchOrder) {
|
||||
return MemorySearchOrderTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is MemoryType) {
|
||||
return MemoryTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
10
mobile/openapi/lib/model/memories_response.dart
generated
10
mobile/openapi/lib/model/memories_response.dart
generated
@@ -13,31 +13,25 @@ part of openapi.api;
|
||||
class MemoriesResponse {
|
||||
/// Returns a new [MemoriesResponse] instance.
|
||||
MemoriesResponse({
|
||||
this.duration = 5,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
int duration;
|
||||
|
||||
bool enabled;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MemoriesResponse &&
|
||||
other.duration == duration &&
|
||||
other.enabled == enabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(duration.hashCode) +
|
||||
(enabled.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MemoriesResponse[duration=$duration, enabled=$enabled]';
|
||||
String toString() => 'MemoriesResponse[enabled=$enabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'duration'] = this.duration;
|
||||
json[r'enabled'] = this.enabled;
|
||||
return json;
|
||||
}
|
||||
@@ -51,7 +45,6 @@ class MemoriesResponse {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MemoriesResponse(
|
||||
duration: mapValueOfType<int>(json, r'duration')!,
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
);
|
||||
}
|
||||
@@ -100,7 +93,6 @@ class MemoriesResponse {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'duration',
|
||||
'enabled',
|
||||
};
|
||||
}
|
||||
|
||||
20
mobile/openapi/lib/model/memories_update.dart
generated
20
mobile/openapi/lib/model/memories_update.dart
generated
@@ -13,19 +13,9 @@ part of openapi.api;
|
||||
class MemoriesUpdate {
|
||||
/// Returns a new [MemoriesUpdate] instance.
|
||||
MemoriesUpdate({
|
||||
this.duration,
|
||||
this.enabled,
|
||||
});
|
||||
|
||||
/// Minimum value: 1
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
int? duration;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@@ -36,25 +26,18 @@ class MemoriesUpdate {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MemoriesUpdate &&
|
||||
other.duration == duration &&
|
||||
other.enabled == enabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(duration == null ? 0 : duration!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MemoriesUpdate[duration=$duration, enabled=$enabled]';
|
||||
String toString() => 'MemoriesUpdate[enabled=$enabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.duration != null) {
|
||||
json[r'duration'] = this.duration;
|
||||
} else {
|
||||
// json[r'duration'] = null;
|
||||
}
|
||||
if (this.enabled != null) {
|
||||
json[r'enabled'] = this.enabled;
|
||||
} else {
|
||||
@@ -72,7 +55,6 @@ class MemoriesUpdate {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MemoriesUpdate(
|
||||
duration: mapValueOfType<int>(json, r'duration'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
);
|
||||
}
|
||||
|
||||
88
mobile/openapi/lib/model/memory_search_order.dart
generated
88
mobile/openapi/lib/model/memory_search_order.dart
generated
@@ -1,88 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class MemorySearchOrder {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const MemorySearchOrder._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const asc = MemorySearchOrder._(r'asc');
|
||||
static const desc = MemorySearchOrder._(r'desc');
|
||||
static const random = MemorySearchOrder._(r'random');
|
||||
|
||||
/// List of all possible values in this [enum][MemorySearchOrder].
|
||||
static const values = <MemorySearchOrder>[
|
||||
asc,
|
||||
desc,
|
||||
random,
|
||||
];
|
||||
|
||||
static MemorySearchOrder? fromJson(dynamic value) => MemorySearchOrderTypeTransformer().decode(value);
|
||||
|
||||
static List<MemorySearchOrder> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MemorySearchOrder>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MemorySearchOrder.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [MemorySearchOrder] to String,
|
||||
/// and [decode] dynamic data back to [MemorySearchOrder].
|
||||
class MemorySearchOrderTypeTransformer {
|
||||
factory MemorySearchOrderTypeTransformer() => _instance ??= const MemorySearchOrderTypeTransformer._();
|
||||
|
||||
const MemorySearchOrderTypeTransformer._();
|
||||
|
||||
String encode(MemorySearchOrder data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a MemorySearchOrder.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
MemorySearchOrder? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'asc': return MemorySearchOrder.asc;
|
||||
case r'desc': return MemorySearchOrder.desc;
|
||||
case r'random': return MemorySearchOrder.random;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [MemorySearchOrderTypeTransformer] instance.
|
||||
static MemorySearchOrderTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,8 @@ import 'package:pigeon/pigeon.dart';
|
||||
class PlatformAsset {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
// Follows AssetType enum from base_asset.model.dart
|
||||
final int type;
|
||||
|
||||
// Seconds since epoch
|
||||
final int? createdAt;
|
||||
final int? updatedAt;
|
||||
@@ -44,7 +42,6 @@ class PlatformAsset {
|
||||
class PlatformAlbum {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
// Seconds since epoch
|
||||
final int? updatedAt;
|
||||
final bool isCloud;
|
||||
@@ -63,7 +60,6 @@ class SyncDelta {
|
||||
final bool hasChanges;
|
||||
final List<PlatformAsset> updates;
|
||||
final List<String> deletes;
|
||||
|
||||
// Asset -> Album mapping
|
||||
final Map<String, List<String>> assetAlbums;
|
||||
|
||||
@@ -111,7 +107,4 @@ abstract class NativeSyncApi {
|
||||
List<HashResult> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false});
|
||||
|
||||
void cancelHashing();
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||
}
|
||||
|
||||
@@ -17,4 +17,3 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockUploadService extends Mock implements UploadService {}
|
||||
|
||||
|
||||
@@ -14,19 +14,16 @@ void main() {
|
||||
late MockLocalAlbumRepository mockAlbumRepo;
|
||||
late MockLocalAssetRepository mockAssetRepo;
|
||||
late MockNativeSyncApi mockNativeApi;
|
||||
late MockTrashedLocalAssetRepository mockTrashedAssetRepo;
|
||||
|
||||
setUp(() {
|
||||
mockAlbumRepo = MockLocalAlbumRepository();
|
||||
mockAssetRepo = MockLocalAssetRepository();
|
||||
mockNativeApi = MockNativeSyncApi();
|
||||
mockTrashedAssetRepo = MockTrashedLocalAssetRepository();
|
||||
|
||||
sut = HashService(
|
||||
localAlbumRepository: mockAlbumRepo,
|
||||
localAssetRepository: mockAssetRepo,
|
||||
nativeSyncApi: mockNativeApi,
|
||||
trashedLocalAssetRepository: mockTrashedAssetRepo,
|
||||
);
|
||||
|
||||
registerFallbackValue(LocalAlbumStub.recent);
|
||||
@@ -117,7 +114,6 @@ void main() {
|
||||
localAssetRepository: mockAssetRepo,
|
||||
nativeSyncApi: mockNativeApi,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mockTrashedAssetRepo,
|
||||
);
|
||||
|
||||
final album = LocalAlbumStub.recent;
|
||||
@@ -190,5 +186,4 @@ void main() {
|
||||
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.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/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../domain/service.mock.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../mocks/asset_entity.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late LocalSyncService sut;
|
||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||
late LocalFilesManagerRepository mockLocalFilesManager;
|
||||
late StorageRepository mockStorageRepository;
|
||||
late MockNativeSyncApi mockNativeSyncApi;
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
|
||||
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
await Store.clear();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockNativeSyncApi = MockNativeSyncApi();
|
||||
|
||||
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
|
||||
(_) async => SyncDelta(
|
||||
hasChanges: false,
|
||||
updates: const [],
|
||||
deletes: const [],
|
||||
assetAlbums: const {},
|
||||
),
|
||||
);
|
||||
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
|
||||
|
||||
sut = LocalSyncService(
|
||||
localAlbumRepository: mockLocalAlbumRepository,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||
localFilesManager: mockLocalFilesManager,
|
||||
storageRepository: mockStorageRepository,
|
||||
nativeSyncApi: mockNativeSyncApi,
|
||||
);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
});
|
||||
|
||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets on non-Android platforms', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
});
|
||||
});
|
||||
|
||||
group('LocalSyncService - syncTrashedAssets behavior', () {
|
||||
test('processes trashed snapshot, restores assets, and trashes local files', () async {
|
||||
final platformAsset = PlatformAsset(
|
||||
id: 'remote-id',
|
||||
name: 'remote.jpg',
|
||||
type: AssetType.image.index,
|
||||
durationInSeconds: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
||||
final restoredIds = ['image1'];
|
||||
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requested, orderedEquals(assetsToRestore));
|
||||
return restoredIds;
|
||||
});
|
||||
|
||||
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {'album-a': [localAssetToTrash]});
|
||||
|
||||
final assetEntity = MockAssetEntity();
|
||||
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
|
||||
|
||||
await sut.processTrashedAssets({'album-a': [platformAsset]});
|
||||
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
||||
|
||||
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
||||
|
||||
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
|
||||
final moveArgs =
|
||||
verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
|
||||
expect(moveArgs, ['content://local-trash']);
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, ['album-a']);
|
||||
expect(trashArgs['album-a'], [localAssetToTrash]);
|
||||
});
|
||||
|
||||
test('does not attempt restore when repository has no assets to restore', () async {
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
||||
});
|
||||
|
||||
test('does not move local assets when repository finds nothing to trash', () async {
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,30 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.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/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../fixtures/sync_stream.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../mocks/asset_entity.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
|
||||
class _AbortCallbackWrapper {
|
||||
const _AbortCallbackWrapper();
|
||||
@@ -46,40 +30,15 @@ void main() {
|
||||
late SyncStreamService sut;
|
||||
late SyncStreamRepository mockSyncStreamRepo;
|
||||
late SyncApiRepository mockSyncApiRepo;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
|
||||
late StorageRepository mockStorageRepo;
|
||||
late Future<void> Function(List<SyncEvent>, Function(), Function()) handleEventsCallback;
|
||||
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
|
||||
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
|
||||
late Drift db;
|
||||
late bool hasManageMediaPermission;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
|
||||
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
await Store.clear();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
successHandler(Invocation _) async => true;
|
||||
|
||||
setUp(() async {
|
||||
setUp(() {
|
||||
mockSyncStreamRepo = MockSyncStreamRepository();
|
||||
mockSyncApiRepo = MockSyncApiRepository();
|
||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
|
||||
mockStorageRepo = MockStorageRepository();
|
||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
|
||||
@@ -128,25 +87,7 @@ void main() {
|
||||
when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler);
|
||||
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
);
|
||||
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
hasManageMediaPermission = false;
|
||||
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
|
||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
sut = SyncStreamService(syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo);
|
||||
});
|
||||
|
||||
Future<void> simulateEvents(List<SyncEvent> events) async {
|
||||
@@ -211,10 +152,6 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
);
|
||||
await sut.sync();
|
||||
@@ -250,10 +187,6 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
);
|
||||
|
||||
@@ -363,127 +296,4 @@ void main() {
|
||||
verify(() => mockSyncApiRepo.ack(["5"])).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group("SyncStreamService - remote trash & restore", () {
|
||||
setUp(() async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
hasManageMediaPermission = true;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
hasManageMediaPermission = false;
|
||||
});
|
||||
|
||||
test("moves backed up local and merged assets to device trash when remote trash events are received", () async {
|
||||
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null);
|
||||
final mergedAsset = LocalAssetStub.image2.copyWith(
|
||||
id: 'merged-local',
|
||||
checksum: 'checksum-merged',
|
||||
remoteId: 'remote-merged',
|
||||
);
|
||||
final assetsByAlbum = {
|
||||
'album-a': [localAsset],
|
||||
'album-b': [mergedAsset],
|
||||
};
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
|
||||
return assetsByAlbum;
|
||||
});
|
||||
|
||||
final localEntity = MockAssetEntity();
|
||||
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
|
||||
|
||||
final mergedEntity = MockAssetEntity();
|
||||
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
|
||||
|
||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
|
||||
final urls = invocation.positionalArguments.first as List<String>;
|
||||
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
|
||||
return true;
|
||||
});
|
||||
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-1',
|
||||
checksum: localAsset.checksum!,
|
||||
ack: 'asset-remote-local-1',
|
||||
trashedAt: DateTime(2025, 5, 1),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-2',
|
||||
checksum: mergedAsset.checksum!,
|
||||
ack: 'asset-remote-merged-2',
|
||||
trashedAt: DateTime(2025, 5, 2),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-3',
|
||||
checksum: 'checksum-remote-only',
|
||||
ack: 'asset-remote-only-3',
|
||||
trashedAt: DateTime(2025, 5, 3),
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
|
||||
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
||||
});
|
||||
|
||||
test("skips device trashing when no local assets match the remote trash payload", () async {
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-only',
|
||||
checksum: 'checksum-only',
|
||||
ack: 'asset-remote-only-9',
|
||||
trashedAt: DateTime(2025, 6, 1),
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||
});
|
||||
|
||||
test("does not request local deletions for permanent remote delete events", () async {
|
||||
final events = [SyncStreamStub.assetDeleteV1];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
});
|
||||
|
||||
test("restores trashed local assets once the matching remote assets leave the trash", () async {
|
||||
final trashedAssets = [
|
||||
LocalAssetStub.image1.copyWith(id: 'trashed-1', checksum: 'checksum-trash', remoteId: 'remote-1'),
|
||||
];
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
||||
|
||||
final restoredIds = ['trashed-1'];
|
||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requestedAssets, orderedEquals(trashedAssets));
|
||||
return restoredIds;
|
||||
});
|
||||
|
||||
final events = [
|
||||
SyncStreamStub.assetModified(
|
||||
id: 'remote-1',
|
||||
checksum: 'checksum-trash',
|
||||
ack: 'asset-remote-1-11',
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
5
mobile/test/drift/main/generated/schema.dart
generated
5
mobile/test/drift/main/generated/schema.dart
generated
@@ -15,7 +15,6 @@ import 'schema_v9.dart' as v9;
|
||||
import 'schema_v10.dart' as v10;
|
||||
import 'schema_v11.dart' as v11;
|
||||
import 'schema_v12.dart' as v12;
|
||||
import 'schema_v13.dart' as v13;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -45,12 +44,10 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v11.DatabaseAtV11(db);
|
||||
case 12:
|
||||
return v12.DatabaseAtV12(db);
|
||||
case 13:
|
||||
return v13.DatabaseAtV13(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
}
|
||||
|
||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
}
|
||||
|
||||
7765
mobile/test/drift/main/generated/schema_v13.dart
generated
7765
mobile/test/drift/main/generated/schema_v13.dart
generated
File diff suppressed because it is too large
Load Diff
63
mobile/test/fixtures/sync_stream.stub.dart
vendored
63
mobile/test/fixtures/sync_stream.stub.dart
vendored
@@ -81,67 +81,4 @@ abstract final class SyncStreamStub {
|
||||
data: SyncMemoryAssetDeleteV1(assetId: "asset-2", memoryId: "memory-1"),
|
||||
ack: "8",
|
||||
);
|
||||
|
||||
static final assetDeleteV1 = SyncEvent(
|
||||
type: SyncEntityType.assetDeleteV1,
|
||||
data: SyncAssetDeleteV1(assetId: "remote-asset"),
|
||||
ack: "asset-delete-ack",
|
||||
);
|
||||
|
||||
static SyncEvent assetTrashed({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String ack,
|
||||
DateTime? trashedAt,
|
||||
}) {
|
||||
return _assetV1(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
deletedAt: trashedAt ?? DateTime(2025, 1, 1),
|
||||
ack: ack,
|
||||
);
|
||||
}
|
||||
|
||||
static SyncEvent assetModified({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String ack,
|
||||
}) {
|
||||
return _assetV1(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
deletedAt: null,
|
||||
ack: ack,
|
||||
);
|
||||
}
|
||||
|
||||
static SyncEvent _assetV1({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required DateTime? deletedAt,
|
||||
required String ack,
|
||||
}) {
|
||||
return SyncEvent(
|
||||
type: SyncEntityType.assetV1,
|
||||
data: SyncAssetV1(
|
||||
checksum: checksum,
|
||||
deletedAt: deletedAt,
|
||||
duration: '0',
|
||||
fileCreatedAt: DateTime(2025),
|
||||
fileModifiedAt: DateTime(2025, 1, 2),
|
||||
id: id,
|
||||
isFavorite: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: DateTime(2025, 1, 3),
|
||||
originalFileName: '$id.jpg',
|
||||
ownerId: 'owner',
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
visibility: AssetVisibility.timeline,
|
||||
),
|
||||
ack: ack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/infrastructure/repositories/storage.repository.dar
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
@@ -35,8 +34,6 @@ class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository
|
||||
|
||||
class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
|
||||
|
||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||
|
||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||
|
||||
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||
@@ -33,7 +33,6 @@ final _activities = [
|
||||
void main() {
|
||||
late ActivityServiceMock activityMock;
|
||||
late ActivityStatisticsMock activityStatisticsMock;
|
||||
late ActivityStatisticsMock albumActivityStatisticsMock;
|
||||
late ProviderContainer container;
|
||||
late AlbumActivityProvider provider;
|
||||
late ListenerMock<AsyncValue<List<Activity>>> listener;
|
||||
@@ -45,23 +44,17 @@ void main() {
|
||||
setUp(() async {
|
||||
activityMock = ActivityServiceMock();
|
||||
activityStatisticsMock = ActivityStatisticsMock();
|
||||
albumActivityStatisticsMock = ActivityStatisticsMock();
|
||||
|
||||
container = TestUtils.createContainer(
|
||||
overrides: [
|
||||
activityServiceProvider.overrideWith((ref) => activityMock),
|
||||
activityStatisticsProvider('test-album', 'test-asset').overrideWith(() => activityStatisticsMock),
|
||||
activityStatisticsProvider('test-album').overrideWith(() => albumActivityStatisticsMock),
|
||||
],
|
||||
);
|
||||
|
||||
// Mock values
|
||||
when(() => activityStatisticsMock.build(any(), any())).thenReturn(0);
|
||||
when(() => albumActivityStatisticsMock.build(any())).thenReturn(0);
|
||||
when(
|
||||
() => activityMock.getAllActivities('test-album', assetId: 'test-asset'),
|
||||
).thenAnswer((_) async => [..._activities]);
|
||||
when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]);
|
||||
|
||||
// Init and wait for providers future to complete
|
||||
provider = albumActivityProvider('test-album', 'test-asset');
|
||||
@@ -96,10 +89,6 @@ void main() {
|
||||
() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'),
|
||||
).thenAnswer((_) async => AsyncData(like));
|
||||
|
||||
final albumProvider = albumActivityProvider('test-album');
|
||||
container.read(albumProvider.notifier);
|
||||
await container.read(albumProvider.future);
|
||||
|
||||
await container.read(provider.notifier).addLike();
|
||||
|
||||
verify(() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'));
|
||||
@@ -110,11 +99,6 @@ void main() {
|
||||
|
||||
// Never bump activity count for new likes
|
||||
verifyNever(() => activityStatisticsMock.addActivity());
|
||||
verifyNever(() => albumActivityStatisticsMock.addActivity());
|
||||
|
||||
final albumActivities = container.read(albumProvider).requireValue;
|
||||
expect(albumActivities, hasLength(5));
|
||||
expect(albumActivities, contains(like));
|
||||
});
|
||||
|
||||
test('Like failed', () async {
|
||||
@@ -123,10 +107,6 @@ void main() {
|
||||
() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'),
|
||||
).thenAnswer((_) async => AsyncError(Exception('Mock'), StackTrace.current));
|
||||
|
||||
final albumProvider = albumActivityProvider('test-album');
|
||||
container.read(albumProvider.notifier);
|
||||
await container.read(albumProvider.future);
|
||||
|
||||
await container.read(provider.notifier).addLike();
|
||||
|
||||
verify(() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'));
|
||||
@@ -134,12 +114,6 @@ void main() {
|
||||
final activities = await container.read(provider.future);
|
||||
expect(activities, hasLength(4));
|
||||
expect(activities, isNot(contains(like)));
|
||||
|
||||
verifyNever(() => albumActivityStatisticsMock.addActivity());
|
||||
|
||||
final albumActivities = container.read(albumProvider).requireValue;
|
||||
expect(albumActivities, hasLength(4));
|
||||
expect(albumActivities, isNot(contains(like)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,7 +130,6 @@ void main() {
|
||||
expect(activities, isNot(anyElement(predicate((Activity a) => a.id == '3'))));
|
||||
|
||||
verifyNever(() => activityStatisticsMock.removeActivity());
|
||||
verifyNever(() => albumActivityStatisticsMock.removeActivity());
|
||||
});
|
||||
|
||||
test('Remove Like failed', () async {
|
||||
@@ -167,9 +140,6 @@ void main() {
|
||||
final activities = await container.read(provider.future);
|
||||
expect(activities, hasLength(4));
|
||||
expect(activities, anyElement(predicate((Activity a) => a.id == '3')));
|
||||
|
||||
verifyNever(() => activityStatisticsMock.removeActivity());
|
||||
verifyNever(() => albumActivityStatisticsMock.removeActivity());
|
||||
});
|
||||
|
||||
test('Comment successfully removed', () async {
|
||||
@@ -181,35 +151,23 @@ void main() {
|
||||
expect(activities, isNot(anyElement(predicate((Activity a) => a.id == '1'))));
|
||||
|
||||
verify(() => activityStatisticsMock.removeActivity());
|
||||
verify(() => albumActivityStatisticsMock.removeActivity());
|
||||
});
|
||||
|
||||
test('Removes activity from album state when asset scoped', () async {
|
||||
when(() => activityMock.removeActivity('3')).thenAnswer((_) async => true);
|
||||
when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]);
|
||||
|
||||
final albumProvider = albumActivityProvider('test-album');
|
||||
container.read(albumProvider.notifier);
|
||||
await container.read(albumProvider.future);
|
||||
|
||||
await container.read(provider.notifier).removeActivity('3');
|
||||
|
||||
final assetActivities = container.read(provider).requireValue;
|
||||
final albumActivities = container.read(albumProvider).requireValue;
|
||||
|
||||
expect(assetActivities, hasLength(3));
|
||||
expect(assetActivities, isNot(anyElement(predicate((Activity a) => a.id == '3'))));
|
||||
|
||||
expect(albumActivities, hasLength(3));
|
||||
expect(albumActivities, isNot(anyElement(predicate((Activity a) => a.id == '3'))));
|
||||
|
||||
verify(() => activityMock.removeActivity('3'));
|
||||
verifyNever(() => activityStatisticsMock.removeActivity());
|
||||
verifyNever(() => albumActivityStatisticsMock.removeActivity());
|
||||
});
|
||||
});
|
||||
|
||||
group('addComment()', () {
|
||||
late ActivityStatisticsMock albumActivityStatisticsMock;
|
||||
|
||||
setUp(() {
|
||||
albumActivityStatisticsMock = ActivityStatisticsMock();
|
||||
container = TestUtils.createContainer(
|
||||
overrides: [
|
||||
activityServiceProvider.overrideWith((ref) => activityMock),
|
||||
activityStatisticsProvider('test-album', 'test-asset').overrideWith(() => activityStatisticsMock),
|
||||
activityStatisticsProvider('test-album').overrideWith(() => albumActivityStatisticsMock),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('Comment successfully added', () async {
|
||||
final comment = Activity(
|
||||
id: '5',
|
||||
@@ -220,10 +178,6 @@ void main() {
|
||||
assetId: 'test-asset',
|
||||
);
|
||||
|
||||
final albumProvider = albumActivityProvider('test-album');
|
||||
container.read(albumProvider.notifier);
|
||||
await container.read(albumProvider.future);
|
||||
|
||||
when(
|
||||
() => activityMock.addActivity(
|
||||
'test-album',
|
||||
@@ -252,10 +206,6 @@ void main() {
|
||||
|
||||
verify(() => activityStatisticsMock.addActivity());
|
||||
verify(() => albumActivityStatisticsMock.addActivity());
|
||||
|
||||
final albumActivities = container.read(albumProvider).requireValue;
|
||||
expect(albumActivities, hasLength(5));
|
||||
expect(albumActivities, contains(comment));
|
||||
});
|
||||
|
||||
test('Comment successfully added without assetId', () async {
|
||||
@@ -275,8 +225,6 @@ void main() {
|
||||
when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]);
|
||||
|
||||
final albumProvider = albumActivityProvider('test-album');
|
||||
container.read(albumProvider.notifier);
|
||||
await container.read(albumProvider.future);
|
||||
await container.read(albumProvider.notifier).addComment('Test-Comment');
|
||||
|
||||
verify(
|
||||
@@ -310,10 +258,6 @@ void main() {
|
||||
),
|
||||
).thenAnswer((_) async => AsyncError(Exception('Error'), StackTrace.current));
|
||||
|
||||
final albumProvider = albumActivityProvider('test-album');
|
||||
container.read(albumProvider.notifier);
|
||||
await container.read(albumProvider.future);
|
||||
|
||||
await container.read(provider.notifier).addComment('Test-Comment');
|
||||
|
||||
final activities = await container.read(provider.future);
|
||||
@@ -322,10 +266,6 @@ void main() {
|
||||
|
||||
verifyNever(() => activityStatisticsMock.addActivity());
|
||||
verifyNever(() => albumActivityStatisticsMock.addActivity());
|
||||
|
||||
final albumActivities = container.read(albumProvider).requireValue;
|
||||
expect(albumActivities, hasLength(4));
|
||||
expect(albumActivities, isNot(contains(comment)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,14 +12,16 @@ import 'package:immich_mobile/infrastructure/repositories/device_asset.repositor
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:immich_mobile/services/hash.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../service.mocks.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
|
||||
class MockAsset extends Mock implements Asset {}
|
||||
|
||||
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||
|
||||
void main() {
|
||||
late HashService sut;
|
||||
late BackgroundService mockBackgroundService;
|
||||
|
||||
@@ -12,12 +12,14 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
|
||||
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||
|
||||
void main() {
|
||||
late UploadService sut;
|
||||
|
||||
@@ -4268,24 +4268,6 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "order",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Number of memories to return",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"required": false,
|
||||
@@ -4399,24 +4381,6 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "order",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Number of memories to return",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"required": false,
|
||||
@@ -12682,27 +12646,18 @@
|
||||
},
|
||||
"MemoriesResponse": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
"default": 5,
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"duration",
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MemoriesUpdate": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -12816,14 +12771,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MemorySearchOrder": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc",
|
||||
"random"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MemoryStatisticsResponseDto": {
|
||||
"properties": {
|
||||
"total": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/node": "^22.18.13",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -152,7 +152,6 @@ export type FoldersResponse = {
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
export type MemoriesResponse = {
|
||||
duration: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
export type PeopleResponse = {
|
||||
@@ -210,7 +209,6 @@ export type FoldersUpdate = {
|
||||
sidebarWeb?: boolean;
|
||||
};
|
||||
export type MemoriesUpdate = {
|
||||
duration?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
export type PeopleUpdate = {
|
||||
@@ -2956,12 +2954,10 @@ export function reverseGeocode({ lat, lon }: {
|
||||
/**
|
||||
* This endpoint requires the `memory.read` permission.
|
||||
*/
|
||||
export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
export function searchMemories({ $for, isSaved, isTrashed, $type }: {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
@@ -2971,8 +2967,6 @@ export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }:
|
||||
"for": $for,
|
||||
isSaved,
|
||||
isTrashed,
|
||||
order,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
...opts
|
||||
@@ -2996,12 +2990,10 @@ export function createMemory({ memoryCreateDto }: {
|
||||
/**
|
||||
* This endpoint requires the `memory.statistics` permission.
|
||||
*/
|
||||
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
export function memoriesStatistics({ $for, isSaved, isTrashed, $type }: {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
@@ -3011,8 +3003,6 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ
|
||||
"for": $for,
|
||||
isSaved,
|
||||
isTrashed,
|
||||
order,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
...opts
|
||||
@@ -4999,11 +4989,6 @@ export enum JobCommand {
|
||||
Empty = "empty",
|
||||
ClearFailed = "clear-failed"
|
||||
}
|
||||
export enum MemorySearchOrder {
|
||||
Asc = "asc",
|
||||
Desc = "desc",
|
||||
Random = "random"
|
||||
}
|
||||
export enum MemoryType {
|
||||
OnThisDay = "on_this_day"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd",
|
||||
"packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
1151
pnpm-lock.yaml
generated
1151
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -26,12 +26,6 @@
|
||||
"matchPackageNames": ["ghcr.io/immich-app/postgres"],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["ruby"],
|
||||
"groupName": "ruby",
|
||||
"matchCurrentVersion": "< 3.4",
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"ignorePaths": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202511041104@sha256:7558931a4a71989e7fd9fa3e1ba6c28da15891867310edda8c58236171839f2f AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202510281104@sha256:e2f94c2e92cbae5982b014e610ff29731c0fbcb4bf69022c7fe27594e40c9f83 AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -48,7 +48,7 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
|
||||
pnpm --filter @immich/sdk --filter @immich/cli build && \
|
||||
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb
|
||||
FROM ghcr.io/immich-app/base-server-prod:202510281104@sha256:84f8f3eb4cfafc5e624235f7db703e1222fd60831bef1d488d8d8cad2be5023d
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user