Compare commits

..

63 Commits

Author SHA1 Message Date
mertalev
89b299e903 update commit 2026-03-12 16:25:08 -05:00
mertalev
770a3925c9 fix video auth 2026-03-12 16:16:32 -05:00
Jason Rasmussen
d4605b21d9 refactor: external links (#26880) 2026-03-12 14:55:33 +00:00
Jason Rasmussen
3bd37ebbfb refactor: clean class (#26879) 2026-03-12 09:53:46 -05:00
Min Idzelis
5c3777ab46 fix(web): fix zoom touch event handling (#26866)
fix(web): fix zoom touch event handling and add clarifying comments

- Suppress Safari's synthetic dblclick on double-tap which conflicts with zoom-image's touchstart-based zoom
- Add comment explaining pointer-events-none on zoom transform wrapper
- Add comments for touchAction and overflow style overrides
2026-03-12 09:37:29 -05:00
Alex
6c531e0a5a chore: add shadow to video play/pause icon shadow (#26836) 2026-03-11 14:15:31 -05:00
Thomas
471c27cd33 chore(mobile): remove background from asset viewer back button (#26851)
We recently changed the asset viewer to use a gradient. The circle
button looks out of place now.
2026-03-11 14:15:18 -05:00
bo0tzz
4773788a88 chore: more unused release workflow cleanup (#26817) 2026-03-11 20:04:26 +01:00
renovate[bot]
d49d995611 chore(deps): update dependency exiftool-vendored to v35.13.1 (#26813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-11 20:03:19 +01:00
Snowknight26
0ac3d6a83a fix(web): face selection box position resetting on browser resize (#26766) 2026-03-11 19:38:08 +01:00
Mees Frensel
9996ee12d0 refactor(web): crop area tool (#26843) 2026-03-11 18:58:26 +01:00
Brendan Ngo
0a79dd1228 fix(server): extract make/model from sony video files (#26833)
Co-authored-by: Your Name <brendan.ngo@okendo.io>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-11 17:29:37 +00:00
Mees Frensel
e45308b949 fix(web): exclude emoji from translation string (#26852) 2026-03-11 13:22:59 -04:00
Mert
c403e03a42 fix(mobile): logout on upgrade (#26827)
* use cookiejar

* cookie duping hook

* remove old pref

* handle network switching on logout

* remove bootstrapCookies

* dead code

* fix cast

* use constants

* use new event name

* update api
2026-03-11 12:07:27 -05:00
Luis Nachtigall
e7db3b220d feat(mobile): show animated images in asset viewer (#26614)
* Add support for showing animated images in AssetViewer with AnimatedImageStreamCompleter

* Add GIF overlay to thumbnail tile for animated assets

* formatting

* require isAnimated parameter in image providers for better asset handling

* feat: refactor AnimatedImageStreamCompleter to use streams for codec loading and initial image handling

* formatting

* add isAnimatedImage property to BaseAsset

* remove ApiService.getRequestHeaders() usage
2026-03-11 12:07:06 -05:00
bo0tzz
28d5c169c0 chore: use pokedex-large runner for rocm (#26823) 2026-03-11 11:15:48 -05:00
renovate[bot]
0f2fe656db fix(deps): update typescript-projects (#26812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-11 16:40:41 +01:00
Daniel Dietzler
34ce68095d chore: upgrade to kysely 0.28.11 (#26744) 2026-03-11 16:17:31 +01:00
Min Idzelis
8764a1894b feat: adaptive progressive image loading for photo viewer (#26636)
* feat(web): adaptive progressive image loading for photo viewer

Replace ImageManager with a new AdaptiveImageLoader that progressively
loads images through quality tiers (thumbnail → preview → original).

New components and utilities:
- AdaptiveImage: layered image renderer with thumbhash, thumbnail,
  preview, and original layers with visibility managed by load state
- AdaptiveImageLoader: state machine driving the quality progression
  with per-quality callbacks and error handling
- ImageLayer/Image: low-level image elements with load/error lifecycle
- PreloadManager: preloads adjacent assets for instant navigation
- AlphaBackground/DelayedLoadingSpinner: loading state UI

Zoom is handled via a derived CSS transform applied to the content
wrapper in AdaptiveImage, with the zoom library (zoomTarget: null)
only tracking state without manipulating the DOM directly.

Also adds scaleToCover to container-utils and getAssetUrls to utils.

* fix: don't partially render images in firefox

* add passive loading indicator to asset-viewer

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-11 09:48:46 -05:00
Michel Heusschen
27f69b39b2 fix(server): use correct day ordering in timeline buckets (#26821)
* fix(web): sort timeline day groups received from server

* fix(server): use correct day ordering in timeline buckets
2026-03-11 08:49:35 -04:00
Michel Heusschen
9fc6fbc373 fix(web): restore asset update events in asset viewer (#26845) 2026-03-11 08:46:29 -04:00
Thomas
9fc32b6f7a feat(mobile): use material design 3 slider (#26829)
* feat(mobile): use material design 3 slider

The new slider is easier to use, and looks more modern.

* chore: add shadow to button and text for better visibility

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-11 01:58:01 +00:00
Thomas
4571940a4e fix(mobile): wrap backup error message text (#26834)
Refs: #25022
2026-03-10 20:40:01 -05:00
Thomas
1ceb6d2e21 fix(mobile): use tabular figures in backup page (#26830)
The numbers in the backup page are not monospace, and so changes cause
the layout to shift. Using tabular figures (monospace) will prevent
that.

Refs: #25021
2026-03-10 20:12:33 -05:00
Andreas Heinz
1a4c5d73ac feat(web): add shortcut "p" to open/close the face tag box (#26826) 2026-03-10 23:53:38 +01:00
renovate[bot]
22b43bf4d9 chore(deps): update dependency @types/node to ^24.11.0 (#26808)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 16:46:21 +00:00
Michel Heusschen
45eff1c663 fix(web): prevent unrelated assets from appearing in tag view (#26816) 2026-03-10 17:43:30 +01:00
renovate[bot]
56b8e1b8a9 chore(deps): update docker.io/valkey/valkey:9 docker digest to 3eeb097 (#26807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 17:34:20 +01:00
Thomas
f79c8cf1c1 feat(mobile): consolidate video controls (#26673)
Videos have recently been changed to support zooming, but this can make
the controls in the centre of the screen unergonomic as they will either
stay in the centre when dismissing, or stick to the video when zooming.
Neither is great. We should align the behaviour with other apps which
has the play/pause toggle at the bottom of the screen with the seeker
bar instead.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-10 10:55:31 -05:00
Min Idzelis
8e50d25f45 feat(web): animate zoom toggle with cubicOut easing (#26731) 2026-03-10 10:42:02 -05:00
Michel Heusschen
8222781d1f fix(web): correct tag rounding in search options (#26814) 2026-03-10 15:25:13 +00:00
bo0tzz
08c4594cde chore: remove release-pr workflow (#26742)
It's not being used right now; can always add it back :P
2026-03-09 16:53:13 -04:00
Daniel Dietzler
d325231df2 chore: refactor test factories (#26804) 2026-03-09 20:47:03 +00:00
Daniil Suvorov
f2726606e0 fix(web): context menu overflow (#26760) 2026-03-09 19:47:54 +01:00
Andreas Heinz
0edbca24e4 fix(web): recalculate face bounding boxes (#26737)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-09 18:44:47 +00:00
Snowknight26
4791d9c0c3 fix(web): show the correct cursor at crop bounds when editing an asset (#26748) 2026-03-09 19:39:01 +01:00
Michel Heusschen
a47b232235 fix(web): refresh recent albums sidebar after album changes (#26757) 2026-03-09 17:11:32 +01:00
Luis Nachtigall
df0c86920d fix(mobile): restrict trashed asset migration to Android platform (#26726)
* fix(migration): restrict trashed asset migration to Android platform

* playbackStyle migration add different log depending on platform
2026-03-09 09:56:27 -05:00
Min Idzelis
422111d26e test(e2e): fix flakiness: optimize resetDatabase with TRUNCATE and fix selectDay selector scoping (#26776)
fix(e2e): optimize resetDatabase with TRUNCATE and fix selectDay selector scoping
2026-03-08 01:38:15 -06:00
Min Idzelis
7a83baaf27 feat: responsive video duration in thumbnail (#26770) 2026-03-08 01:37:41 -06:00
Aleksander Pejcic
aaf34fa7d4 feat(ml): enable openvino for cpu (#22948)
* Enable OpenVINO CPU acceleration in Immich

* Remove unnecessary debug log

* Removing checking for device_ids for openvino since cpu will always be available

* Find OpenVINOExecutionProvider index instead of assuming index 0

* Fix openvino tests

* Fix failing test mock. OpenVINO expects provider options, but cuda provide doesn't so use that for mocked tests.

* Support empty provider options in OrtSessions in which case ONNXRuntime will use its own defaults

* Use OpenVINOExecutionProvider for test_sets_provider_options_kwarg

* fix mock

* simplify

* unused variable

---------

Co-authored-by: Aleksander <pejcic@adobe.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-03-07 18:40:43 +00:00
Sergey Katsubo
4a384bca86 fix(server): opus handling as accepted audio codec in transcode policy (#26736)
* Fix opus handling as accepted audio codec in transcode policy

Fix the issue when opus is among accepted audio codecs in transcode policy
(which is default) but it still triggers transcoding because the codec name
from ffprobe (opus) does not match `libopus` literal in Immich.

Make a distinction between a codec name and encoder:
- codec name: switch to `opus` as the audio codec name. This matches what ffprobe
returns for a media file with opus audio.
- encoder: continue using the `libopus` encoder in ffmpeg.

* Add unit tests for accepted audio codecs and for libopus encoder

* Add db migration for ffmpeg.targetAudioCodec opus

* backward compatibility

* tweak

* noisy logs

* full mapping

* make check happy

* mark deprecated

* update api

* indexOf

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-03-07 13:08:42 -05:00
Thomas
dd72ec2621 fix(mobile): correct local asset dimensions (#26677)
* fix(mobile): correct local asset dimensions

We are constraining the size of videos so that they play nicely with
hero animations, and don't stretch in weird ways. This however caused a
regression as we are not account for local assets on Android which have
un-oriented dimensions.

* post-orientation width and height in local sync

* migration

* no need to handle it in asset viewer

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-07 13:07:34 -05:00
Luis Nachtigall
e73686bd76 feat(android): enhance playback style detection using MIME type, reducing glide exposure (#26747)
* feat(android): enhance playback style detection using MIME type

* feat(android): improve playback style detection for GIF and WebP formats

* fix(android): make playback style detection faster

* refactor(android): simplify XMP reading logic for API 29 and below

* update playback style detection documentation

* use DefaultImageHeaderParser instead of all available ones for webp playbackStyle type detection
2026-03-07 10:41:26 -05:00
Snowknight26
6e9a425592 fix(web): asset viewer showing wrong viewer type when hovering on stack thumbnails (#26741) 2026-03-06 21:17:11 +01:00
Thomas
6012d22d98 fix(mobile): incorrect asset dimensions in search (#26725)
Search results use a different provider than the main timeline, and they
appear appear to have diverged a bit. This means that assets can
sometimes look wrong or different in search compared to the main
timeline or albums.
2026-03-05 21:58:58 -06:00
Min Idzelis
abfcffb423 feat(web): toggle zoom on double-click in photo viewer (#26732) 2026-03-05 21:58:09 -06:00
Min Idzelis
ec7246b86f refactor(web): add --font-sans CSS variable for primary font (#26730) 2026-03-06 03:00:37 +00:00
Brandon Wees
9597f8c37f feat(mobile): SyncAssetEditV1 (#26518)
* feat(mobile): SyncAssetEditV1

* fix: websocket handling

* fix: server version requirement

* fix: revert pubspec changes
2026-03-05 13:03:59 -06:00
Alex
7b0deb1fd3 fix: playback style migration (#26718) 2026-03-05 12:51:58 -06:00
Thomas
5ab05e57fa fix(mobile): inconsistent asset details background (#26634)
The background of the photo view does not extend below the height of the
viewport, and so the asset details fade in over black with the photo
view, and the standard surface colour scheme of the scaffold for the
rest. This leads to a janky animation. We can't change the background of
the scaffold to black, as it in turn makes the iOS bouncing scroll
physics cut off incorrectly. The best fix is to remove background
decoration from the photo view, and defer to the parent to colour the
background.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-05 17:45:21 +00:00
Marvin M
ba3f114625 chore: always use Package Imports (#26630)
* chore: always_use_package_imports

* fix: lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-05 11:18:01 -06:00
Mees Frensel
9b642633c1 fix(server): clarify transcoding bitrate policy (#26711) 2026-03-05 12:13:29 -05:00
Mert
a05c8c6087 feat(mobile): use shared native client (#25942)
* use shared client in dart

fix android

* websocket integration

platform-side headers

update comment

consistent platform check

tweak websocket handling

support streaming

* redundant logging

* fix proguard

* formatting

* handle onProgress

* support videos on ios

* inline return

* improved ios impl

* cleanup

* sync stopForegroundBackup

* voidify

* future already completed

* stream request on android

* outdated ios ws code

* use `choosePrivateKeyAlias`

* return result

* formatting

* update tests

* redundant check

* handle custom headers

* move completer outside of state

* persist auth

* dispose old socket

* use group id for cookies

* redundant headers

* cache global ref

* handle network switching

* handle basic auth

* apply custom headers immediately

* video player update

* fix

* persist url

* potential logout fix

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-05 11:04:45 -06:00
Mert
35a521c6ec fix(ml): batch size setting (#26524) 2026-03-05 12:01:47 -05:00
Snowknight26
09fabb36b6 fix(web): video stealing focus when it plays again when looping (#26704) 2026-03-05 15:41:27 +00:00
Daniel Dietzler
c259fee309 chore: cleanup vscode settings (#26709) 2026-03-05 08:12:59 -05:00
renovate[bot]
78ba9cbc63 chore(deps): update dependency multer to v2.1.1 [security] (#26705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-05 10:59:51 +01:00
Michel Heusschen
33d75462c9 fix(web): combobox dropdown positioning in modals (#26707) 2026-03-05 10:58:26 +01:00
Mees Frensel
e9451f10d6 chore(web): small cleanup of timeline month (#26708) 2026-03-05 09:53:20 +00:00
Min Idzelis
480b7e8d65 chore: configure ESLint flat config and auto-fix on save in VSCode settings (#26679) 2026-03-04 14:55:02 -05:00
Thomas
228ac63ab9 feat(mobile): keep search results visible (#26498)
Search results are replaced with a spinner when loading the next page,
which is quite jarring. Search results now remain visible when loading
the next page with a spinner at the bottom. The next page also loads
sooner, which makes it feel a lot smoother.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-04 11:27:11 -06:00
Thomas
7e9da945f6 chore(mobile): simplify asset page scroll (#26635)
In order to scroll smoothly without interfering with the gesture
detector on the photo view, we have an offstate scroll view which we
defer all drags to, and then forward scroll offsets to the real scroll
controller. This works well, but it can be simpler. Instead, we can
create a custom scroll controller on a scroll view with never scrollable
physics, and then forward drag events to that, bypassing the need for a
proxy scroll controller.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-04 10:28:55 -06:00
394 changed files with 23318 additions and 10087 deletions

View File

@@ -16,7 +16,7 @@ services:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../packages/plugin-core:/build/plugins/immich-plugin-core
- ../plugins:/build/corePlugin
immich-web:
env_file: !reset []
immich-machine-learning:

View File

@@ -131,7 +131,7 @@ jobs:
- device: rocm
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions:
contents: read

View File

@@ -1,170 +0,0 @@
name: Manage release PR
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
bump:
runs-on: ubuntu-latest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Determine release type
id: bump-type
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
with:
token: ${{ steps.generate-token.outputs.token }}
- name: Bump versions
env:
TYPE: ${{ steps.bump-type.outputs.bump }}
run: |
if [ "$TYPE" == "none" ]; then
exit 1 # TODO: Is there a cleaner way to abort the workflow?
fi
misc/release/pump-version.sh -s $TYPE -m true
- name: Manage Outline release document
id: outline
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const fs = require('fs');
const outlineKey = process.env.OUTLINE_API_KEY;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
let documentId;
let documentUrl;
let documentText;
if (!document) {
// Create new document
console.log('No existing document found. Creating new one...');
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'next',
text: notesTmpl,
collectionId: collectionId,
parentDocumentId: parentDocumentId,
publish: true
})
});
if (!createResponse.ok) {
throw new Error(`Failed to create document: ${createResponse.statusText}`);
}
const createData = await createResponse.json();
documentId = createData.data.id;
const urlId = createData.data.urlId;
documentUrl = `${baseUrl}/doc/next-${urlId}`;
documentText = createData.data.text || '';
console.log(`Created new document: ${documentUrl}`);
} else {
documentId = document.id;
const docPath = document.url;
documentUrl = `${baseUrl}${docPath}`;
documentText = document.text || '';
console.log(`Found existing document: ${documentUrl}`);
}
// Generate GitHub release notes
console.log('Generating GitHub release notes...');
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `${process.env.NEXT_VERSION}`,
});
// Combine the content
const changelog = `
# ${process.env.NEXT_VERSION}
${documentText}
${releaseNotesResponse.data.body}
---
`
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
core.setOutput('document_url', documentUrl);
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
labels: 'changelog:skip'
branch: 'release/next'
draft: true

View File

@@ -1,149 +0,0 @@
name: release.yml
on:
pull_request:
types: [closed]
paths:
- CHANGELOG.md
jobs:
# Maybe double check PR source branch?
merge_translations:
uses: ./.github/workflows/merge-translations.yml
permissions:
pull-requests: write
secrets:
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: merge_translations
permissions:
contents: read
secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
# iOS secrets
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: main
environment: production
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
permissions:
actions: read # To download the app artifact
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Extract changelog
id: changelog
run: |
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}
body_path: ${{ steps.changelog.outputs.path }}
draft: true
files: |
docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk
- name: Rename Outline document
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
continue-on-error: true
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
VERSION: ${{ steps.changelog.outputs.version }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const outlineKey = process.env.OUTLINE_API_KEY;
const version = process.env.VERSION;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
if (document) {
console.log(`Found document 'next', renaming to '${version}'...`);
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: document.id,
title: version
})
});
if (!updateResponse.ok) {
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
}
} else {
console.log('No document titled "next" found to rename');
}

View File

@@ -90,8 +90,6 @@ jobs:
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Build app
run: pnpm run --filter immich --filter @immich/plugin-sdk build
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
@@ -396,8 +394,6 @@ jobs:
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build plugin
run: pnpm run --filter @immich/plugin-sdk --filter @immich/plugin-core build
- name: Run medium tests
run: pnpm test:medium
if: ${{ !cancelled() }}
@@ -726,7 +722,7 @@ jobs:
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Build the app
run: pnpm --filter immich --filter @immich/plugin-sdk build
run: pnpm --filter immich build
- name: Run API generation
run: ./bin/generate-open-api.sh
working-directory: open-api
@@ -788,7 +784,7 @@ jobs:
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build the app
run: pnpm run --filter immich --filter @immich/plugin-sdk build
run: pnpm build
- name: Run existing migrations
run: pnpm migrations:run
- name: Test npm run schema:reset command works

48
.vscode/settings.json vendored
View File

@@ -1,8 +1,7 @@
{
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[dart]": {
"editor.defaultFormatter": "Dart-Code.dart-code",
@@ -19,18 +18,15 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[svelte]": {
"editor.codeActionsOnSave": {
@@ -38,8 +34,7 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[typescript]": {
"editor.codeActionsOnSave": {
@@ -47,18 +42,45 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"cSpell.words": ["immich"],
"css.lint.unknownAtRules": "ignore",
"editor.bracketPairColorization.enabled": true,
"editor.formatOnSave": true,
"eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "svelte"],
"eslint.workingDirectories": [
{ "directory": "cli", "changeProcessCWD": true },
{ "directory": "e2e", "changeProcessCWD": true },
{ "directory": "server", "changeProcessCWD": true },
{ "directory": "web", "changeProcessCWD": true }
],
"files.watcherExclude": {
"**/.jj/**": true,
"**/.git/**": true,
"**/node_modules/**": true,
"**/build/**": true,
"**/dist/**": true,
"**/.svelte-kit/**": true
},
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
},
"search.exclude": {
"**/node_modules": true,
"**/build": true,
"**/dist": true,
"**/.svelte-kit": true,
"**/open-api/typescript-sdk/src": true
},
"svelte.enable-ts-plugin": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
"tailwindCSS.experimental.configFile": {
"web/src/app.css": "web/src/**"
},
"js/ts.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10
}

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.14",
"@types/node": "^24.11.0",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -73,7 +73,7 @@ services:
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../packages/plugin-core:/build/plugins/immich-plugin-core
- ../plugins:/build/corePlugin
env_file:
- .env
environment:
@@ -155,7 +155,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
user: '1000:1000'
security_opt:
- no-new-privileges:true

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -230,7 +230,7 @@ The default value is `ultrafast`.
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
The default value is `aac`.

View File

@@ -27,7 +27,7 @@ The default configuration looks like this:
"ffmpeg": {
"accel": "disabled",
"accelDecode": false,
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
"acceptedContainers": ["mov", "ogg", "webm"],
"acceptedVideoCodecs": ["h264"],
"bframes": -1,

View File

@@ -166,6 +166,8 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |

View File

@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.10.14",
"@types/node": "^24.11.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",

View File

@@ -1,14 +1,13 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import type { Socket } from 'socket.io-client';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let rawAsset: AssetMediaResponseDto;
let websocket: Socket;
test.beforeAll(async () => {
utils.initSdk();
@@ -16,6 +15,11 @@ test.describe('Photo Viewer', () => {
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
websocket = await utils.connectWebsocket(admin.accessToken);
});
test.afterAll(() => {
utils.disconnectWebsocket(websocket);
});
test.beforeEach(async ({ context, page }) => {
@@ -26,31 +30,51 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
await originalResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /original/);
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
await fullsizeResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /fullsize/);
});
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const initialSrc = await preview.getAttribute('src');
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await utils.replaceAsset(admin.accessToken, asset.id);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
await websocketEvent;
await expect(preview).not.toHaveAttribute('src', initialSrc!);
});
});

View File

@@ -64,7 +64,9 @@ test.describe('broken-asset responsiveness', () => {
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
(url) =>
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
async (route) => {
return route.fulfill({ status: 404 });
},
@@ -73,7 +75,7 @@ test.describe('broken-asset responsiveness', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();

View File

@@ -215,8 +215,9 @@ export const pageUtils = {
await page.getByText('Confirm').click();
},
async selectDay(page: Page, day: string) {
await page.getByTitle(day).hover();
await page.locator('[data-group] .w-8').click();
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
await section.hover();
await section.locator('.w-8').click();
},
async pauseTestDebug() {
console.log('NOTE: pausing test indefinately for debug');

View File

@@ -177,40 +177,51 @@ export const utils = {
},
resetDatabase: async (tables?: string[]) => {
try {
client = await utils.connectDatabase();
client = await utils.connectDatabase();
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
'stack',
'library',
'shared_link',
'person',
'album',
'asset',
'asset_face',
'activity',
'api_key',
'session',
'user',
'system_metadata',
'tag',
];
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
'stack',
'library',
'shared_link',
'person',
'album',
'asset',
'asset_face',
'activity',
'api_key',
'session',
'user',
'system_metadata',
'tag',
];
const sql: string[] = [];
const truncateTables = tables.filter((table) => table !== 'system_metadata');
const sql: string[] = [];
for (const table of tables) {
if (table === 'system_metadata') {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else {
sql.push(`DELETE FROM "${table}" CASCADE;`);
if (truncateTables.length > 0) {
sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`);
}
if (tables.includes('system_metadata')) {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
}
const query = sql.join('\n');
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await client.query(query);
return;
} catch (error: any) {
if (error?.code === '40P01' && attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
continue;
}
console.error('Failed to reset database', error);
throw error;
}
await client.query(sql.join('\n'));
} catch (error) {
console.error('Failed to reset database', error);
throw error;
}
},

View File

@@ -22,12 +22,13 @@
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
"add_path": "Add path",
"add_photos": "Add photos",
"add_step": "Add step",
"add_tag": "Add tag",
"add_to": "Add to…",
"add_to_album": "Add to album",
@@ -41,6 +42,7 @@
"add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites",
@@ -409,7 +411,7 @@
"transcoding_tone_mapping": "Tone-mapping",
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
"transcoding_transcode_policy": "Transcode policy",
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos and videos with a pixel format other than YUV 4:2:0 will always be transcoded (except if transcoding is disabled).",
"transcoding_two_pass_encoding": "Two-pass encoding",
"transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
"transcoding_video_codec": "Video codec",
@@ -803,7 +805,6 @@
"comments_are_disabled": "Comments are disabled",
"common_create_new_album": "Create new album",
"completed": "Completed",
"configuration": "Configuration",
"confirm": "Confirm",
"confirm_admin_password": "Confirm Admin Password",
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
@@ -1006,6 +1007,8 @@
"editor_edits_applied_success": "Edits applied successfully",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle",
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle",
"editor_orientation": "Orientation",
"editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise",
@@ -1071,7 +1074,7 @@
"failed_to_update_notification_status": "Failed to update notification status",
"incorrect_email_or_password": "Incorrect email or password",
"library_folder_already_exists": "This import path already exists.",
"page_not_found": "Page not found :/",
"page_not_found": "Page not found",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
@@ -1582,6 +1585,7 @@
"next": "Next",
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_found": "No albums found",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
@@ -1598,6 +1602,7 @@
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
@@ -1610,7 +1615,6 @@
"no_results": "No results",
"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_steps": "No steps added yet",
"no_uploads_in_progress": "No uploads in progress",
"none": "None",
"not_allowed": "Not allowed",
@@ -2179,7 +2183,6 @@
"start_date_before_end_date": "Start date must be before end date",
"state": "State",
"status": "Status",
"steps": "Steps",
"stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?",
@@ -2310,6 +2313,7 @@
"unsupported_field_type": "Unsupported field type",
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated",
@@ -2400,7 +2404,6 @@
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",

View File

@@ -48,8 +48,11 @@ class PreloadModelData(BaseModel):
class MaxBatchSize(BaseModel):
ocr_fallback: str | None = os.getenv("MACHINE_LEARNING_MAX_BATCH_SIZE__TEXT_RECOGNITION", None)
if ocr_fallback is not None:
os.environ["MACHINE_LEARNING_MAX_BATCH_SIZE__OCR"] = ocr_fallback
facial_recognition: int | None = None
text_recognition: int | None = None
ocr: int | None = None
class Settings(BaseSettings):

View File

@@ -29,7 +29,7 @@ class FaceRecognizer(InferenceModel):
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
super().__init__(model_name, **model_kwargs)
max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None
max_batch_size = settings.max_batch_size and settings.max_batch_size.facial_recognition
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
def _load(self) -> ModelSession:

View File

@@ -22,7 +22,7 @@ class TextDetector(InferenceModel):
depends = []
identity = (ModelType.DETECTION, ModelTask.OCR)
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
def __init__(self, model_name: str, min_score: float = 0.5, **model_kwargs: Any) -> None:
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
self.max_resolution = 736
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
@@ -33,7 +33,7 @@ class TextDetector(InferenceModel):
}
self.postprocess = DBPostProcess(
thresh=0.3,
box_thresh=model_kwargs.get("minScore", 0.5),
box_thresh=model_kwargs.get("minScore", min_score),
max_candidates=1000,
unclip_ratio=1.6,
use_dilation=True,

View File

@@ -24,9 +24,9 @@ class TextRecognizer(InferenceModel):
depends = [(ModelType.DETECTION, ModelTask.OCR)]
identity = (ModelType.RECOGNITION, ModelTask.OCR)
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
def __init__(self, model_name: str, min_score: float = 0.9, **model_kwargs: Any) -> None:
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
self.min_score = model_kwargs.get("minScore", 0.9)
self.min_score = model_kwargs.get("minScore", min_score)
self._empty: TextRecognitionOutput = {
"box": np.empty(0, dtype=np.float32),
"boxScore": np.empty(0, dtype=np.float32),
@@ -57,10 +57,11 @@ class TextRecognizer(InferenceModel):
def _load(self) -> ModelSession:
# TODO: support other runtimes
session = OrtSession(self.model_path)
max_batch_size = settings.max_batch_size and settings.max_batch_size.ocr
self.model = RapidTextRecognizer(
OcrOptions(
session=session.session,
rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6,
rec_batch_num=max_batch_size if max_batch_size else 6,
rec_img_shape=(3, 48, 320),
lang_type=self.language,
)

View File

@@ -64,14 +64,6 @@ class OrtSession:
def _providers_default(self) -> list[str]:
available_providers = set(ort.get_available_providers())
log.debug(f"Available ORT providers: {available_providers}")
if (openvino := "OpenVINOExecutionProvider") in available_providers:
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
log.debug(f"Available OpenVINO devices: {device_ids}")
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
if not gpu_devices:
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
available_providers.remove(openvino)
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
@property
@@ -102,12 +94,19 @@ class OrtSession:
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
}
case "OpenVINOExecutionProvider":
openvino_dir = self.model_path.parent / "openvino"
device = f"GPU.{settings.device_id}"
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
# Check for available devices, preferring GPU over CPU
gpu_devices = [d for d in device_ids if d.startswith("GPU")]
if gpu_devices:
device_type = f"GPU.{settings.device_id}"
log.debug(f"OpenVINO: Using GPU device {device_type}")
else:
device_type = "CPU"
log.debug("OpenVINO: No GPU found, using CPU")
options = {
"device_type": device,
"device_type": device_type,
"precision": settings.openvino_precision.value,
"cache_dir": openvino_dir.as_posix(),
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
}
case "CoreMLExecutionProvider":
options = {
@@ -139,12 +138,14 @@ class OrtSession:
sess_options.enable_cpu_mem_arena = settings.model_arena
# avoid thread contention between models
# Set inter_op threads
if settings.model_inter_op_threads > 0:
sess_options.inter_op_num_threads = settings.model_inter_op_threads
# these defaults work well for CPU, but bottleneck GPU
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
sess_options.inter_op_num_threads = 1
# Set intra_op threads
if settings.model_intra_op_threads > 0:
sess_options.intra_op_num_threads = settings.model_intra_op_threads
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:

View File

@@ -18,7 +18,7 @@ from PIL import Image
from pytest import MonkeyPatch
from pytest_mock import MockerFixture
from immich_ml.config import Settings, settings
from immich_ml.config import MaxBatchSize, Settings, settings
from immich_ml.main import load, preload_models
from immich_ml.models.base import InferenceModel
from immich_ml.models.cache import ModelCache
@@ -26,6 +26,9 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn
from immich_ml.models.clip.visual import OpenClipVisualEncoder
from immich_ml.models.facial_recognition.detection import FaceDetector
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
from immich_ml.models.ocr.detection import TextDetector
from immich_ml.models.ocr.recognition import TextRecognizer
from immich_ml.models.ocr.schemas import OcrOptions
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
from immich_ml.sessions.ann import AnnSession
from immich_ml.sessions.ort import OrtSession
@@ -201,13 +204,6 @@ class TestOrtSession:
assert session.providers == self.OV_EP
@pytest.mark.ov_device_ids(["CPU"])
@pytest.mark.providers(OV_EP)
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], ov_device_ids: list[str]) -> None:
session = OrtSession("ViT-B-32__openai")
assert session.providers == self.CPU_EP
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
session = OrtSession("ViT-B-32__openai")
@@ -253,7 +249,8 @@ class TestOrtSession:
{"arena_extend_strategy": "kSameAsRequested"},
]
def test_sets_provider_options_for_openvino(self) -> None:
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
@@ -267,7 +264,8 @@ class TestOrtSession:
}
]
def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None:
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
@@ -282,6 +280,19 @@ class TestOrtSession:
}
]
@pytest.mark.ov_device_ids(["CPU"])
def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/model.onnx"
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
assert session.provider_options == [
{
"device_type": "CPU",
"precision": "FP32",
"cache_dir": "/cache/ViT-B-32__openai/openvino",
}
]
def test_sets_provider_options_for_cuda(self) -> None:
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
@@ -338,6 +349,23 @@ class TestOrtSession:
assert session.sess_options.inter_op_num_threads == 1
assert session.sess_options.intra_op_num_threads == 2
@pytest.mark.ov_device_ids(["CPU"])
def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/model.onnx"
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
assert session.sess_options.inter_op_num_threads == 0
assert session.sess_options.intra_op_num_threads == 0
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/model.onnx"
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
assert session.sess_options.inter_op_num_threads == 0
assert session.sess_options.intra_op_num_threads == 0
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
@@ -855,6 +883,78 @@ class TestFaceRecognition:
onnx.load.assert_not_called()
onnx.save.assert_not_called()
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
assert recognizer.batch_size == 2
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
assert recognizer.batch_size is None
class TestOcr:
def test_set_det_min_score(self, path: mock.Mock) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
text_detector = TextDetector("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
assert text_detector.postprocess.box_thresh == 0.8
def test_set_rec_min_score(self, path: mock.Mock) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
text_recognizer = TextRecognizer("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
assert text_recognizer.min_score == 0.8
def test_set_rec_set_default_max_batch_size(
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
mocker.patch("immich_ml.models.base.InferenceModel.download")
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
)
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
mocker.patch("immich_ml.models.base.InferenceModel.download")
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=4))
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
)
def test_ignore_other_custom_max_batch_size(
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
mocker.patch("immich_ml.models.base.InferenceModel.download")
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=3))
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
)
@pytest.mark.asyncio
class TestCache:

View File

@@ -2,7 +2,7 @@ experimental_monorepo_root = true
[monorepo]
config_roots = [
"packages/plugin-core",
"plugins",
"server",
"cli",
"deployment",

View File

@@ -9,7 +9,7 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
formatter:
formatter:
page_width: 120
linter:
@@ -33,6 +33,7 @@ linter:
require_trailing_commas: true
unrelated_type_equality_checks: true
prefer_const_constructors: true
always_use_package_imports: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -3,6 +3,7 @@ plugins {
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
}
@@ -81,6 +82,7 @@ android {
release {
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
namespace 'app.alextran.immich'
@@ -111,6 +113,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"

View File

@@ -36,4 +36,12 @@
##---------------End: proguard configuration for Gson ----------
# Keep all widget model classes and their fields for Gson
-keep class app.alextran.immich.widget.model.** { *; }
-keep class app.alextran.immich.widget.model.** { *; }
##---------------Begin: proguard configuration for ok_http JNI ----------
# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI
# string-based reflection (JClass.forName), which R8 cannot trace.
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
-keep class com.example.ok_http.** { *; }
##---------------End: proguard configuration for ok_http JNI ----------

View File

@@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy(
memcpy((void *) destAddress, (char *) src + offset, length);
}
}
/**
* Creates a JNI global reference to the given object and returns its address.
* The caller is responsible for deleting the global reference when it's no longer needed.
*/
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
if (obj == NULL) {
return 0;
}
jobject globalRef = (*env)->NewGlobalRef(env, obj);
return (jlong) globalRef;
}

View File

@@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin
import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
@@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() {
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger

View File

@@ -23,6 +23,9 @@ object NativeBuffer {
@JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
@JvmStatic
external fun createGlobalRef(obj: Any): Long
}
class NativeByteBuffer(initialCapacity: Int) {

View File

@@ -1,11 +1,30 @@
package app.alextran.immich.core
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import androidx.annotation.OptIn
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer
import okhttp3.Cache
import okhttp3.ConnectionPool
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.Credentials
import okhttp3.Dispatcher
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.File
import java.net.Socket
@@ -13,6 +32,8 @@ import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
@@ -20,14 +41,31 @@ import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509KeyManager
import javax.net.ssl.X509TrustManager
const val CERT_ALIAS = "client_cert"
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
private const val CERT_ALIAS = "client_cert"
private const val PREFS_NAME = "immich.ssl"
private const val PREFS_CERT_ALIAS = "immich.client_cert"
private const val PREFS_HEADERS = "immich.request_headers"
private const val PREFS_SERVER_URLS = "immich.server_urls"
private const val PREFS_COOKIES = "immich.cookies"
private const val COOKIE_EXPIRY_DAYS = 400L
private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
ACCESS_TOKEN("immich_access_token", httpOnly = true),
IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false),
AUTH_TYPE("immich_auth_type", httpOnly = true);
companion object {
val names = entries.map { it.cookieName }.toSet()
}
}
/**
* Manages a shared OkHttpClient with SSL configuration support.
*/
object HttpClientManager {
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64
@@ -36,22 +74,74 @@ object HttpClientManager {
private val clientChangedListeners = mutableListOf<() -> Unit>()
private lateinit var client: OkHttpClient
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
var cronetEngine: CronetEngine? = null
private set
private lateinit var cronetStorageDir: File
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS)
var keyChainAlias: String? = null
private set
var headers: Headers = Headers.headersOf()
private set
private val cookieJar = PersistentCookieJar()
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
cookieJar.init(prefs)
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
if (savedHeaders != null) {
val map = Json.decodeFromString<Map<String, String>>(savedHeaders)
val builder = Headers.Builder()
for ((key, value) in map) {
builder.add(key, value)
}
headers = builder.build()
}
val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null)
if (serverUrlsJson != null) {
cookieJar.setServerUrls(Json.decodeFromString<List<String>>(serverUrlsJson))
}
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
cronetEngine = buildCronetEngine()
initialized = true
}
}
fun setKeyChainAlias(alias: String) {
synchronized(this) {
val wasMtls = isMtls
keyChainAlias = alias
prefs.edit { putString(PREFS_CERT_ALIAS, alias) }
if (wasMtls != isMtls) {
clientChangedListeners.forEach { it() }
}
}
}
fun setKeyEntry(clientData: ByteArray, password: CharArray) {
synchronized(this) {
val wasMtls = isMtls
@@ -63,7 +153,7 @@ object HttpClientManager {
val key = tmpKeyStore.getKey(tmpAlias, password)
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
if (wasMtls) {
if (keyStore.containsAlias(CERT_ALIAS)) {
keyStore.deleteEntry(CERT_ALIAS)
}
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
@@ -75,24 +165,130 @@ object HttpClientManager {
fun deleteKeyEntry() {
synchronized(this) {
if (!isMtls) {
return
val wasMtls = isMtls
if (keyChainAlias != null) {
keyChainAlias = null
prefs.edit { remove(PREFS_CERT_ALIAS) }
}
keyStore.deleteEntry(CERT_ALIAS)
clientChangedListeners.forEach { it() }
if (wasMtls) {
clientChangedListeners.forEach { it() }
}
}
}
private var clientGlobalRef: Long = 0L
@JvmStatic
fun getClient(): OkHttpClient {
return client
}
fun getClientPointer(): Long {
if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client)
}
return clientGlobalRef
}
fun addClientChangedListener(listener: () -> Unit) {
synchronized(this) { clientChangedListeners.add(listener) }
}
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>, token: String?) {
synchronized(this) {
val builder = Headers.Builder()
headerMap.forEach { (key, value) -> builder[key] = value }
val newHeaders = builder.build()
val headersChanged = headers != newHeaders
val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null)
headers = newHeaders
cookieJar.setServerUrls(serverUrls)
if (headersChanged || urlsChanged) {
prefs.edit {
putString(PREFS_HEADERS, Json.encodeToString(headerMap))
putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls))
}
}
if (token != null) {
val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return
val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000
val values = mapOf(
AuthCookie.ACCESS_TOKEN to token,
AuthCookie.IS_AUTHENTICATED to "true",
AuthCookie.AUTH_TYPE to "password",
)
cookieJar.saveFromResponse(url, values.map { (cookie, value) ->
Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry)
.apply {
if (url.isHttps) secure()
if (cookie.httpOnly) httpOnly()
}.build()
})
}
}
}
fun loadCookieHeader(url: String): String? {
val httpUrl = url.toHttpUrlOrNull() ?: return null
return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() }
?.joinToString("; ") { "${it.name}=${it.value}" }
}
fun getAuthHeaders(url: String): Map<String, String> {
val result = mutableMapOf<String, String>()
headers.forEach { (key, value) -> result[key] = value }
loadCookieHeader(url)?.let { result["Cookie"] = it }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
}
}
return result
}
fun rebuildCronetEngine(): CronetEngine {
val old = cronetEngine!!
cronetEngine = buildCronetEngine()
return old
}
val cronetStoragePath: File get() = cronetStorageDir
@OptIn(UnstableApi::class)
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
return if (isMtls) {
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
} else {
ResolvingDataSource.Factory(
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
) { dataSpec ->
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
newHeaders["Cache-Control"] = "no-store"
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
}
}
}
private fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(cronetStorageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
.build()
}
private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -109,8 +305,17 @@ object HttpClientManager {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
return OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build())
.cookieJar(cookieJar)
.addInterceptor {
val request = it.request()
val builder = request.newBuilder()
builder.header("User-Agent", USER_AGENT)
headers.forEach { (key, value) -> builder.header(key, value) }
val url = request.url
if (url.username.isNotEmpty()) {
builder.header("Authorization", Credentials.basic(url.username, url.password))
}
it.proceed(builder.build())
}
.connectionPool(connectionPool)
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
@@ -119,23 +324,39 @@ object HttpClientManager {
.build()
}
// Reads from the key store rather than taking a snapshot at initialization time
/**
* Resolves client certificates dynamically at TLS handshake time.
* Checks the system KeyChain alias first, then falls back to the app's private KeyStore.
*/
private class DynamicKeyManager : X509KeyManager {
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
if (isMtls) arrayOf(CERT_ALIAS) else null
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? {
val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null
return arrayOf(alias)
}
override fun chooseClientAlias(
keyTypes: Array<String>,
issuers: Array<Principal>?,
socket: Socket?
): String? =
if (isMtls) CERT_ALIAS else null
): String? {
keyChainAlias?.let { return it }
if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS
return null
}
override fun getCertificateChain(alias: String): Array<X509Certificate>? =
keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
override fun getCertificateChain(alias: String): Array<X509Certificate>? {
if (alias == keyChainAlias) {
return KeyChain.getCertificateChain(appContext, alias)
}
return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
}
override fun getPrivateKey(alias: String): PrivateKey? =
keyStore.getKey(alias, null) as? PrivateKey
override fun getPrivateKey(alias: String): PrivateKey? {
if (alias == keyChainAlias) {
return KeyChain.getPrivateKey(appContext, alias)
}
return keyStore.getKey(alias, null) as? PrivateKey
}
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
null
@@ -146,4 +367,131 @@ object HttpClientManager {
socket: Socket?
): String? = null
}
/**
* Persistent CookieJar that duplicates auth cookies across equivalent server URLs.
* When the server sets cookies for one domain, copies are created for all other known
* server domains (for URL switching between local/remote endpoints of the same server).
*/
private class PersistentCookieJar : CookieJar {
private val store = mutableListOf<Cookie>()
private var serverUrls = listOf<HttpUrl>()
private var prefs: SharedPreferences? = null
fun init(prefs: SharedPreferences) {
this.prefs = prefs
restore()
}
@Synchronized
fun setServerUrls(urls: List<String>) {
val parsed = urls.mapNotNull { it.toHttpUrlOrNull() }
if (parsed.map { it.host } == serverUrls.map { it.host }) return
serverUrls = parsed
if (syncAuthCookies()) persist()
}
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val changed = cookies.any { new ->
store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value }
}
store.removeAll { existing ->
cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
}
store.addAll(cookies)
val synced = serverUrls.any { it.host == url.host } && syncAuthCookies()
if (changed || synced) persist()
}
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val now = System.currentTimeMillis()
if (store.removeAll { it.expiresAt < now }) {
syncAuthCookies()
persist()
}
return store.filter { it.matches(url) }
}
private fun syncAuthCookies(): Boolean {
val serverHosts = serverUrls.map { it.host }.toSet()
val now = System.currentTimeMillis()
val sourceCookies = store
.filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now }
.associateBy { it.name }
if (sourceCookies.isEmpty()) {
return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts }
}
var changed = false
for (url in serverUrls) {
for ((_, source) in sourceCookies) {
if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue
store.removeAll { it.name == source.name && it.domain == url.host }
store.add(rebuildCookie(source, url))
changed = true
}
}
return changed
}
private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie {
return Cookie.Builder()
.name(source.name).value(source.value)
.domain(url.host).path("/")
.expiresAt(source.expiresAt)
.apply {
if (url.isHttps) secure()
if (source.httpOnly) httpOnly()
}
.build()
}
private fun persist() {
val p = prefs ?: return
p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) }
}
private fun restore() {
val p = prefs ?: return
val jsonStr = p.getString(PREFS_COOKIES, null) ?: return
try {
store.addAll(Json.decodeFromString<List<SerializedCookie>>(jsonStr).map { it.toCookie() })
} catch (_: Exception) {
store.clear()
}
}
}
@Serializable
private data class SerializedCookie(
val name: String,
val value: String,
val domain: String,
val path: String,
val expiresAt: Long,
val secure: Boolean,
val httpOnly: Boolean,
val hostOnly: Boolean,
) {
fun toCookie(): Cookie = Cookie.Builder()
.name(name).value(value).path(path).expiresAt(expiresAt)
.apply {
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
if (secure) secure()
if (httpOnly) httpOnly()
}
.build()
companion object {
fun from(cookie: Cookie) = SerializedCookie(
name = cookie.name, value = cookie.value, domain = cookie.domain,
path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure,
httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly,
)
}
}
}

View File

@@ -180,8 +180,11 @@ private open class NetworkPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NetworkApi {
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit)
fun removeCertificate(callback: (Result<Unit>) -> Unit)
fun hasCertificate(): Boolean
fun getClientPointer(): Long
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?)
companion object {
/** The codec used by NetworkApi. */
@@ -217,13 +220,12 @@ interface NetworkApi {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val promptTextArg = args[0] as ClientCertPrompt
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> ->
api.selectCertificate(promptTextArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(NetworkPigeonUtils.wrapResult(data))
reply.reply(NetworkPigeonUtils.wrapResult(null))
}
}
}
@@ -248,6 +250,56 @@ interface NetworkApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasCertificate())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getClientPointer())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val headersArg = args[0] as Map<String, String>
val serverUrlsArg = args[1] as List<String>
val tokenArg = args[2] as String?
val wrapped: List<Any?> = try {
api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg)
listOf(null)
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -2,20 +2,9 @@ package app.alextran.immich.core
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.OperationCanceledException
import android.text.InputType
import android.view.ContextThemeWrapper
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import android.security.KeyChain
import app.alextran.immich.NativeBuffer
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -24,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
private var networkApi: NetworkApiImpl? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
networkApi = NetworkApiImpl(binding.applicationContext)
networkApi = NetworkApiImpl()
NetworkApi.setUp(binding.binaryMessenger, networkApi)
}
@@ -34,48 +23,24 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
networkApi?.onAttachedToActivity(binding)
networkApi?.activity = binding.activity
}
override fun onDetachedFromActivityForConfigChanges() {
networkApi?.onDetachedFromActivityForConfigChanges()
networkApi?.activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
networkApi?.onReattachedToActivityForConfigChanges(binding)
networkApi?.activity = binding.activity
}
override fun onDetachedFromActivity() {
networkApi?.onDetachedFromActivity()
networkApi?.activity = null
}
}
private class NetworkApiImpl(private val context: Context) : NetworkApi {
private var activity: Activity? = null
private var pendingCallback: ((Result<ClientCertData>) -> Unit)? = null
private var filePicker: ActivityResultLauncher<Array<String>>? = null
private var promptText: ClientCertPrompt? = null
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
(binding.activity as? ComponentActivity)?.let { componentActivity ->
filePicker = componentActivity.registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) }
}
}
fun onDetachedFromActivityForConfigChanges() {
activity = null
}
fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}
fun onDetachedFromActivity() {
activity = null
}
private class NetworkApiImpl : NetworkApi {
var activity: Activity? = null
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
try {
@@ -86,11 +51,19 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi {
}
}
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) {
val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity")))
pendingCallback = callback
this.promptText = promptText
picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file"))
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit) {
val currentActivity = activity
?: return callback(Result.failure(IllegalStateException("No activity")))
val onAlias = { alias: String? ->
if (alias != null) {
HttpClientManager.setKeyChainAlias(alias)
callback(Result.success(Unit))
} else {
callback(Result.failure(OperationCanceledException()))
}
}
KeyChain.choosePrivateKeyAlias(currentActivity, onAlias, null, null, null, null)
}
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
@@ -98,62 +71,15 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi {
callback(Result.success(Unit))
}
private fun handlePickedFile(uri: Uri) {
val callback = pendingCallback ?: return
pendingCallback = null
try {
val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalStateException("Could not read file")
val activity = activity ?: throw IllegalStateException("No activity")
promptForPassword(activity) { password ->
promptText = null
if (password == null) {
callback(Result.failure(OperationCanceledException()))
return@promptForPassword
}
try {
HttpClientManager.setKeyEntry(data, password.toCharArray())
callback(Result.success(ClientCertData(data, password)))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
} catch (e: Exception) {
callback(Result.failure(e))
}
override fun hasCertificate(): Boolean {
return HttpClientManager.isMtls
}
private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) {
val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog)
val density = activity.resources.displayMetrics.density
val horizontalPadding = (24 * density).toInt()
override fun getClientPointer(): Long {
return HttpClientManager.getClientPointer()
}
val textInputLayout = TextInputLayout(themedContext).apply {
hint = "Password"
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
setMargins(horizontalPadding, 0, horizontalPadding, 0)
}
}
val editText = TextInputEditText(textInputLayout.context).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}
textInputLayout.addView(editText)
val container = FrameLayout(themedContext).apply { addView(textInputLayout) }
val text = promptText!!
MaterialAlertDialogBuilder(themedContext)
.setTitle(text.title)
.setMessage(text.message)
.setView(container)
.setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) }
.setNegativeButton(text.cancel) { _, _ -> callback(null) }
.setOnCancelListener { callback(null) }
.show()
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) {
HttpClientManager.setRequestHeaders(headers, serverUrls, token)
}
}

View File

@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface RemoteImageApi {
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit)
@@ -66,10 +66,9 @@ interface RemoteImageApi {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val urlArg = args[0] as String
val headersArg = args[1] as Map<String, String>
val requestIdArg = args[2] as Long
val preferEncodedArg = args[3] as Boolean
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val requestIdArg = args[1] as Long
val preferEncodedArg = args[2] as Boolean
api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))

View File

@@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.USER_AGENT
import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
@@ -15,7 +14,6 @@ import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
@@ -29,10 +27,6 @@ import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -49,7 +43,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun requestImage(
url: String,
headers: Map<String, String>,
requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit
@@ -59,7 +52,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
ImageFetcherManager.fetch(
url,
headers,
signal,
onSuccess = { buffer ->
requestMap.remove(requestId)
@@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
}
private object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
private var initialized = false
@@ -110,7 +101,6 @@ private object ImageFetcherManager {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
cacheDir = context.cacheDir
fetcher = build()
HttpClientManager.addClientChangedListener(::invalidate)
@@ -120,12 +110,11 @@ private object ImageFetcherManager {
fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
fetcher.fetch(url, headers, signal, onSuccess, onFailure)
fetcher.fetch(url, signal, onSuccess, onFailure)
}
fun clearCache(onCleared: (Result<Long>) -> Unit) {
@@ -144,7 +133,7 @@ private object ImageFetcherManager {
return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir)
} else {
CronetImageFetcher(appContext, cacheDir)
CronetImageFetcher()
}
}
}
@@ -152,7 +141,6 @@ private object ImageFetcherManager {
private sealed interface ImageFetcher {
fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
@@ -163,23 +151,14 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit)
}
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
private class CronetImageFetcher : ImageFetcher {
private val stateLock = Any()
private var activeCount = 0
private var draining = false
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
init {
engine = build(context)
}
override fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
@@ -193,24 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val requestBuilder = HttpClientManager.cronetEngine!!
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
requestBuilder.addHeader(key, value)
}
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
request.start()
}
private fun build(ctx: Context): CronetEngine {
return CronetEngine.Builder(ctx)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(storageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
.build()
}
private fun onComplete() {
val didDrain = synchronized(stateLock) {
activeCount--
@@ -233,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}
private fun onDrained() {
engine.shutdown()
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
if (onCacheCleared == null) {
executor.shutdown()
} else {
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine()
oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
engine = build(ctx)
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
@@ -372,7 +340,7 @@ private class OkHttpImageFetcher private constructor(
val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
.build()
return OkHttpImageFetcher(client)
@@ -391,7 +359,6 @@ private class OkHttpImageFetcher private constructor(
override fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
@@ -404,7 +371,6 @@ private class OkHttpImageFetcher private constructor(
}
val requestBuilder = Request.Builder().url(url)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val call = client.newCall(requestBuilder.build())
signal.setOnCancelListener(call::cancel)

View File

@@ -16,6 +16,7 @@ import app.alextran.immich.core.ImmichPlugin
import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -81,10 +82,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
}
if (hasSpecialFormatColumn()) {
add(SPECIAL_FORMAT_COLUMN)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Fallback: read XMP from MediaStore to detect Motion Photos
// only needed if SPECIAL_FORMAT column isn't available
add(MediaStore.MediaColumns.XMP)
} else {
// fallback to mimetype and xmp for playback style detection on older Android versions
// both only needed if special format column is not available
add(MediaStore.MediaColumns.MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.XMP)
}
}
}.toTypedArray()
@@ -131,6 +135,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
@@ -177,19 +182,20 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val playbackStyle = detectPlaybackStyle(
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c
)
val isFlipped = orientation == 90 || orientation == 270
val asset = PlatformAsset(
id,
name,
assetType,
createdAt,
modifiedAt,
width,
height,
if (isFlipped) height else width,
if (isFlipped) width else height,
duration,
orientation.toLong(),
0L,
isFavorite,
playbackStyle = playbackStyle,
)
@@ -200,13 +206,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
}
/**
* Detects the playback style for an asset using _special_format (API 33+)
* or XMP / MIME / RIFF header fallbacks (pre-33).
* Detects the playback style for an asset using _special_format (SDK Extension 21+)
* or XMP / MIME / RIFF header fallbacks.
*/
@SuppressLint("NewApi")
private fun detectPlaybackStyle(
assetId: Long,
rawMediaType: Int,
mimeTypeColumn: Int,
specialFormatColumn: Int,
xmpColumn: Int,
cursor: Cursor
@@ -231,46 +238,56 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
return PlatformAssetPlaybackStyle.UNKNOWN
}
// Pre-API 33 fallback
val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null
// GIFs are always animated and cannot be motion photos; no I/O needed
if (mimeType == "image/gif") {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
val uri = ContentUris.withAppendedId(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
assetId
)
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
// Only WebP needs a stream check to distinguish static vs animated;
// WebP files are not used as motion photos, so skip XMP detection
if (mimeType == "image/webp") {
try {
val glide = Glide.get(ctx)
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val type = ImageHeaderParserUtils.getType(
listOf(DefaultImageHeaderParser()),
stream,
glide.arrayPool
)
// Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance
if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
// if mimeType is webp but not animated, its just an image.
return PlatformAssetPlaybackStyle.IMAGE
}
// Read XMP from cursor (API 30+)
val xmp: String? = if (xmpColumn != -1) {
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
} else {
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
null
}
// if xmp column is not available, we are on API 29 or below
// theoretically there were motion photos but the Camera:MotionPhoto xmp tag
// was only added in Android 11, so we should not have to worry about parsing XMP on older versions
null
}
if (xmp != null && "Camera:MotionPhoto" in xmp) {
return PlatformAssetPlaybackStyle.LIVE_PHOTO
}
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val glide = Glide.get(ctx)
val type = ImageHeaderParserUtils.getType(
glide.registry.imageHeaderParsers,
stream,
glide.arrayPool
)
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
return PlatformAssetPlaybackStyle.IMAGE
}

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,6 @@
import BackgroundTasks
import Flutter
import native_video_player
import network_info_plus
import path_provider_foundation
import permission_handler_apple
@@ -18,6 +19,7 @@ import UIKit
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine, controller: controller)

View File

@@ -221,8 +221,11 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NetworkApi {
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, Error>) -> Void)
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, Error>) -> Void)
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
func hasCertificate() throws -> Bool
func getClientPointer() throws -> Int64
func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -255,8 +258,8 @@ class NetworkApiSetup {
let promptTextArg = args[0] as! ClientCertPrompt
api.selectCertificate(promptText: promptTextArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
@@ -280,5 +283,48 @@ class NetworkApiSetup {
} else {
removeCertificateChannel.setMessageHandler(nil)
}
let hasCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasCertificateChannel.setMessageHandler { _, reply in
do {
let result = try api.hasCertificate()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
hasCertificateChannel.setMessageHandler(nil)
}
let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getClientPointerChannel.setMessageHandler { _, reply in
do {
let result = try api.getClientPointer()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getClientPointerChannel.setMessageHandler(nil)
}
let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
setRequestHeadersChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let headersArg = args[0] as! [String: String]
let serverUrlsArg = args[1] as! [String]
let tokenArg: String? = nilOrValue(args[2])
do {
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
setRequestHeadersChannel.setMessageHandler(nil)
}
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
import UniformTypeIdentifiers
import native_video_player
enum ImportError: Error {
case noFile
@@ -16,14 +17,25 @@ class NetworkApiImpl: NetworkApi {
self.viewController = viewController
}
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, any Error>) -> Void) {
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, any Error>) -> Void) {
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
self?.activeImporter = nil
completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) })
completion(result)
}, viewController: viewController)
activeImporter = importer
importer.load()
}
func hasCertificate() throws -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecReturnRef as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
return status == errSecSuccess
}
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
let status = clearCerts()
@@ -40,14 +52,55 @@ class NetworkApiImpl: NetworkApi {
}
completion(.failure(ImportError.keychainError(status)))
}
func getClientPointer() throws -> Int64 {
let pointer = URLSessionManager.shared.sessionPointer
return Int64(Int(bitPattern: pointer))
}
func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws {
URLSessionManager.setServerUrls(serverUrls)
if let token = token {
let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60)
for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https")
let values: [AuthCookie: String] = [
.accessToken: token,
.isAuthenticated: "true",
.authType: "password",
]
for (cookie, value) in values {
var properties: [HTTPCookiePropertyKey: Any] = [
.name: cookie.name,
.value: value,
.domain: domain,
.path: "/",
.expires: expiry,
]
if isSecure { properties[.secure] = "TRUE" }
if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" }
if let httpCookie = HTTPCookie(properties: properties) {
URLSessionManager.cookieStorage.setCookie(httpCookie)
}
}
}
}
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
URLSessionManager.shared.recreateSession()
}
}
}
private class CertImporter: NSObject, UIDocumentPickerDelegate {
private let promptText: ClientCertPrompt
private var completion: ((Result<(Data, String), Error>) -> Void)
private var completion: ((Result<Void, Error>) -> Void)
private weak var viewController: UIViewController?
init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) {
init(promptText: ClientCertPrompt, completion: (@escaping (Result<Void, Error>) -> Void), viewController: UIViewController?) {
self.promptText = promptText
self.completion = completion
self.viewController = viewController
@@ -81,7 +134,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate {
}
await URLSessionManager.shared.session.flush()
self.completion(.success((data, password)))
self.completion(.success(()))
} catch {
completion(.failure(error))
}

View File

@@ -1,49 +1,172 @@
import Foundation
import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers"
let SERVER_URLS_KEY = "immich.server_urls"
let APP_GROUP = "group.app.immich.share"
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
enum AuthCookie: CaseIterable {
case accessToken, isAuthenticated, authType
var name: String {
switch self {
case .accessToken: return "immich_access_token"
case .isAuthenticated: return "immich_is_authenticated"
case .authType: return "immich_auth_type"
}
}
var httpOnly: Bool {
switch self {
case .accessToken, .authType: return true
case .isAuthenticated: return false
}
}
static let names: Set<String> = Set(allCases.map(\.name))
}
extension UserDefaults {
static let group = UserDefaults(suiteName: APP_GROUP)!
}
/// Manages a shared URLSession with SSL configuration support.
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
class URLSessionManager: NSObject {
static let shared = URLSessionManager()
let session: URLSession
private let configuration = {
let config = URLSessionConfiguration.default
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
private(set) var session: URLSession
let delegate: URLSessionManagerDelegate
private static let cacheDir: URL = {
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("api", isDirectory: true)
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
config.urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
private static let urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir
)
private static let userAgent: String = {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
return "Immich_iOS_\(version)"
}()
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
private static var serverUrls: [String] = []
private static var isSyncing = false
var sessionPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(session).toOpaque()
}
private override init() {
delegate = URLSessionManagerDelegate()
session = Self.buildSession(delegate: delegate)
super.init()
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
NotificationCenter.default.addObserver(
Self.self,
selector: #selector(Self.cookiesDidChange),
name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged,
object: Self.cookieStorage
)
}
func recreateSession() {
session = Self.buildSession(delegate: delegate)
}
static func setServerUrls(_ urls: [String]) {
guard urls != serverUrls else { return }
serverUrls = urls
UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY)
syncAuthCookies()
}
@objc private static func cookiesDidChange(_ notification: Notification) {
guard !isSyncing, !serverUrls.isEmpty else { return }
syncAuthCookies()
}
private static func syncAuthCookies() {
let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host })
let allCookies = cookieStorage.cookies ?? []
let now = Date()
let serverAuthCookies = allCookies.filter {
AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain)
}
var sourceCookies: [String: HTTPCookie] = [:]
for cookie in serverAuthCookies {
if cookie.expiresDate.map({ $0 > now }) ?? true {
sourceCookies[cookie.name] = cookie
}
}
isSyncing = true
defer { isSyncing = false }
if sourceCookies.isEmpty {
for cookie in serverAuthCookies {
cookieStorage.deleteCookie(cookie)
}
return
}
for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https")
for (_, source) in sourceCookies {
if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) {
continue
}
var properties: [HTTPCookiePropertyKey: Any] = [
.name: source.name,
.value: source.value,
.domain: domain,
.path: "/",
.expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60),
]
if isSecure { properties[.secure] = "TRUE" }
if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" }
if let cookie = HTTPCookie(properties: properties) {
cookieStorage.setCookie(cookie)
}
}
}
}
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
let config = URLSessionConfiguration.default
config.urlCache = urlCache
config.httpCookieStorage = cookieStorage
config.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
return config
}()
private override init() {
session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil)
super.init()
var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
headers["User-Agent"] = headers["User-Agent"] ?? userAgent
config.httpAdditionalHeaders = headers
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}
}
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(challenge, completionHandler: completionHandler)
handleChallenge(session, challenge, completionHandler)
}
func urlSession(
@@ -52,20 +175,24 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(challenge, completionHandler: completionHandler)
handleChallenge(session, challenge, completionHandler, task: task)
}
func handleChallenge(
_ session: URLSession,
_ challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
_ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void,
task: URLSessionTask? = nil
) {
switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler)
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler)
case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler)
default: completionHandler(.performDefaultHandling, nil)
}
}
private func handleClientCertificate(
_ session: URLSession,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let query: [String: Any] = [
@@ -80,8 +207,29 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
return completion(.useCredential, credential)
}
completion(.performDefaultHandling, nil)
}
private func handleBasicAuth(
_ session: URLSession,
task: URLSessionTask?,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let url = task?.originalRequest?.url,
let user = url.user,
let password = url.password
else {
return completion(.performDefaultHandling, nil)
}
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
let credential = URLCredential(user: user, password: password, persistence: .forSession)
completion(.useCredential, credential)
}
}

View File

@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi {
func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
}
@@ -86,10 +86,9 @@ class RemoteImageApiSetup {
requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let urlArg = args[0] as! String
let headersArg = args[1] as! [String: String]
let requestIdArg = args[2] as! Int64
let preferEncodedArg = args[3] as! Bool
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
let requestIdArg = args[1] as! Int64
let preferEncodedArg = args[2] as! Bool
api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))

View File

@@ -33,12 +33,9 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary
func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad
for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)

View File

@@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
const Color red400 = Color(0xFFEF5350);
const Color grey200 = Color(0xFFEEEEEE);

View File

@@ -46,6 +46,7 @@ sealed class BaseAsset {
bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => livePhotoVideoId != null;
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
AssetPlaybackStyle get playbackStyle {
if (isVideo) return AssetPlaybackStyle.video;

View File

@@ -0,0 +1,21 @@
import "package:openapi/api.dart" as api show AssetEditAction;
enum AssetEditAction { rotate, crop, mirror, other }
extension AssetEditActionExtension on AssetEditAction {
api.AssetEditAction? toDto() {
return switch (this) {
AssetEditAction.rotate => api.AssetEditAction.rotate,
AssetEditAction.crop => api.AssetEditAction.crop,
AssetEditAction.mirror => api.AssetEditAction.mirror,
AssetEditAction.other => null,
};
}
}
class AssetEdit {
final AssetEditAction action;
final Map<String, dynamic> parameters;
const AssetEdit({required this.action, required this.parameters});
}

View File

@@ -3,30 +3,21 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult {
final List<BaseAsset> assets;
final double scrollOffset;
final int? nextPage;
const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
scrollOffset: scrollOffset ?? this.scrollOffset,
);
}
const SearchResult({required this.assets, this.nextPage});
@override
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
@override
bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
return listEquals(other.assets, assets) && other.nextPage == nextPage;
}
@override
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
int get hashCode => assets.hashCode ^ nextPage.hashCode;
}

View File

@@ -3,7 +3,6 @@ import 'dart:io';
import 'dart:ui';
import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -28,7 +27,6 @@ import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
@@ -64,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift;
final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi;
final CancellationToken _cancellationToken = CancellationToken();
final _cancellationToken = Completer<void>();
final Logger _logger = Logger('BackgroundWorkerBgService');
bool _isCleanedUp = false;
@@ -88,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> init() async {
try {
HttpSSLOptions.apply();
await Future.wait(
[
loadTranslations(),
@@ -198,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_ref?.dispose();
_ref = null;
_cancellationToken.cancel();
_cancellationToken.complete();
_logger.info("Cleaning up background worker");
final cleanupFutures = [

View File

@@ -70,13 +70,14 @@ extension on AssetResponseDto {
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
stackId: stack?.id,
isEdited: isEdited,
);
}

View File

@@ -205,6 +205,10 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetEditV1:
return _syncStreamRepository.updateAssetEditsV1(data.cast());
case SyncEntityType.assetEditDeleteV1:
return _syncStreamRepository.deleteAssetEditsV1(data.cast());
case SyncEntityType.assetMetadataV1:
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
case SyncEntityType.assetMetadataDeleteV1:
@@ -336,39 +340,43 @@ class SyncStreamService {
}
}
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) return;
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
final List<SyncAssetV1> assets = [];
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
_logger.info('Processing AssetEditReadyV1 event');
try {
for (final data in batchData) {
if (data is! Map<String, dynamic>) {
continue;
}
final payload = data;
final assetData = payload['asset'];
if (assetData == null) {
continue;
}
final asset = SyncAssetV1.fromJson(assetData);
if (asset != null) {
assets.add(asset);
}
if (data is! Map<String, dynamic>) {
throw ArgumentError("Invalid data format for AssetEditReadyV1 event");
}
if (assets.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
_logger.info('Successfully processed ${assets.length} edited assets');
final payload = data;
if (payload['asset'] == null) {
throw ArgumentError("Missing 'asset' field in AssetEditReadyV1 event data");
}
final asset = SyncAssetV1.fromJson(payload['asset']);
if (asset == null) {
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV1 event data");
}
List<SyncAssetEditV1> assetEdits = [];
// Edits are only send on v2.6.0+
if (payload['edit'] != null && payload['edit'] is List<dynamic>) {
assetEdits = (payload['edit'] as List<dynamic>)
.map((e) => SyncAssetEditV1.fromJson(e))
.whereType<SyncAssetEditV1>()
.toList();
}
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
_logger.info(
'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits',
);
} catch (error, stackTrace) {
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
_logger.severe("Error processing AssetEditReadyV1 websocket event", error, stackTrace);
}
}

View File

@@ -78,6 +78,9 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));
TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
@@ -112,7 +115,7 @@ class TimelineService {
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer.clear();
_buffer = [];
} else {
final int offset;
final int count;

View File

@@ -196,11 +196,11 @@ class BackgroundSyncManager {
});
}
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
Future<void> syncWebsocketEdit(dynamic data) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
_syncWebsocketTask = _handleWsAssetEditReadyV1(data);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
@@ -242,7 +242,7 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
debugLabel: 'websocket-batch',
);
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
debugLabel: 'websocket-edit',
);

View File

@@ -33,12 +33,27 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
);
}
class SnapScrollController extends ScrollController {
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
@override
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) =>
SnapScrollPosition(physics: physics, context: context, oldPosition: oldPosition);
}
class SnapScrollPosition extends ScrollPositionWithSingleContext {
double snapOffset;
SnapScrollPosition({required super.physics, required super.context, super.oldPosition, this.snapOffset = 0.0});
@override
bool get shouldIgnorePointer => false;
}
class SnapScrollPhysics extends ScrollPhysics {
static const _minFlingVelocity = 700.0;
static const minSnapDistance = 30.0;
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
const SnapScrollPhysics({super.parent});
@override
@@ -66,91 +81,21 @@ class SnapScrollPhysics extends ScrollPhysics {
}
}
return ScrollSpringSimulation(
_spring,
position.pixels,
target(position, velocity, snapOffset),
velocity,
tolerance: toleranceFor(position),
);
return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity);
}
@override
SpringDescription get spring => SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
@override
bool get allowImplicitScrolling => false;
@override
bool get allowUserScrolling => false;
static double target(ScrollMetrics position, double velocity, double snapOffset) {
if (velocity > _minFlingVelocity) return snapOffset;
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
}
}
class SnapScrollPosition extends ScrollPositionWithSingleContext {
double snapOffset;
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
}
class ProxyScrollController extends ScrollController {
final ScrollController scrollController;
ProxyScrollController({required this.scrollController});
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
@override
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
return ProxyScrollPosition(
scrollController: scrollController,
physics: physics,
context: context,
oldPosition: oldPosition,
);
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
}
class ProxyScrollPosition extends SnapScrollPosition {
final ScrollController scrollController;
ProxyScrollPosition({
required this.scrollController,
required super.physics,
required super.context,
super.oldPosition,
});
@override
double setPixels(double newPixels) {
final overscroll = super.setPixels(newPixels);
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
scrollController.position.forcePixels(pixels);
}
return overscroll;
}
@override
void forcePixels(double value) {
super.forcePixels(value);
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
scrollController.position.forcePixels(pixels);
}
}
@override
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
? scrollController.position.maxScrollExtent
: super.maxScrollExtent;
@override
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
? scrollController.position.minScrollExtent
: super.minScrollExtent;
@override
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
? scrollController.position.viewportDimension
: super.viewportDimension;
}

View File

@@ -0,0 +1,33 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)')
class AssetEditEntity extends Table with DriftDefaultsMixin {
const AssetEditEntity();
TextColumn get id => text()();
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
IntColumn get action => intEnum<AssetEditAction>()();
BlobColumn get parameters => blob().map(editParameterConverter)();
IntColumn get sequence => integer()();
@override
Set<Column> get primaryKey => {id};
}
final JsonTypeConverter2<Map<String, Object?>, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb(
fromJson: (json) => json as Map<String, Object?>,
);
extension AssetEditEntityDataDomainEx on AssetEditEntityData {
AssetEdit toDto() {
return AssetEdit(action: action, parameters: parameters);
}
}

View File

@@ -0,0 +1,752 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i1;
import 'package:immich_mobile/domain/models/asset_edit.model.dart' as i2;
import 'dart:typed_data' as i3;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i5;
import 'package:drift/internal/modular.dart' as i6;
typedef $$AssetEditEntityTableCreateCompanionBuilder =
i1.AssetEditEntityCompanion Function({
required String id,
required String assetId,
required i2.AssetEditAction action,
required Map<String, Object?> parameters,
required int sequence,
});
typedef $$AssetEditEntityTableUpdateCompanionBuilder =
i1.AssetEditEntityCompanion Function({
i0.Value<String> id,
i0.Value<String> assetId,
i0.Value<i2.AssetEditAction> action,
i0.Value<Map<String, Object?>> parameters,
i0.Value<int> sequence,
});
final class $$AssetEditEntityTableReferences
extends
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$AssetEditEntityTable,
i1.AssetEditEntityData
> {
$$AssetEditEntityTableReferences(
super.$_db,
super.$_table,
super.$_typedResult,
);
static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$AssetEditEntityTable>('asset_edit_entity')
.assetId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
i5.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
final manager = i5
.$$RemoteAssetEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer(
$_db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
}
class $$AssetEditEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnWithTypeConverterFilters<i2.AssetEditAction, i2.AssetEditAction, int>
get action => $composableBuilder(
column: $table.action,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnWithTypeConverterFilters<
Map<String, Object?>,
Map<String, Object>,
i3.Uint8List
>
get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnFilters<int> get sequence => $composableBuilder(
column: $table.sequence,
builder: (column) => i0.ColumnFilters(column),
);
i5.$$RemoteAssetEntityTableFilterComposer get assetId {
final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get action => $composableBuilder(
column: $table.action,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<i3.Uint8List> get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get sequence => $composableBuilder(
column: $table.sequence,
builder: (column) => i0.ColumnOrderings(column),
);
i5.$$RemoteAssetEntityTableOrderingComposer get assetId {
final i5.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int> get action =>
$composableBuilder(column: $table.action, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<Map<String, Object?>, i3.Uint8List>
get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => column,
);
i0.GeneratedColumn<int> get sequence =>
$composableBuilder(column: $table.sequence, builder: (column) => column);
i5.$$RemoteAssetEntityTableAnnotationComposer get assetId {
final i5.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$AssetEditEntityTable,
i1.AssetEditEntityData,
i1.$$AssetEditEntityTableFilterComposer,
i1.$$AssetEditEntityTableOrderingComposer,
i1.$$AssetEditEntityTableAnnotationComposer,
$$AssetEditEntityTableCreateCompanionBuilder,
$$AssetEditEntityTableUpdateCompanionBuilder,
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
i1.AssetEditEntityData,
i0.PrefetchHooks Function({bool assetId})
> {
$$AssetEditEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$AssetEditEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$AssetEditEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$AssetEditEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () => i1
.$$AssetEditEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> id = const i0.Value.absent(),
i0.Value<String> assetId = const i0.Value.absent(),
i0.Value<i2.AssetEditAction> action = const i0.Value.absent(),
i0.Value<Map<String, Object?>> parameters =
const i0.Value.absent(),
i0.Value<int> sequence = const i0.Value.absent(),
}) => i1.AssetEditEntityCompanion(
id: id,
assetId: assetId,
action: action,
parameters: parameters,
sequence: sequence,
),
createCompanionCallback:
({
required String id,
required String assetId,
required i2.AssetEditAction action,
required Map<String, Object?> parameters,
required int sequence,
}) => i1.AssetEditEntityCompanion.insert(
id: id,
assetId: assetId,
action: action,
parameters: parameters,
sequence: sequence,
),
withReferenceMapper: (p0) => p0
.map(
(e) => (
e.readTable(table),
i1.$$AssetEditEntityTableReferences(db, table, e),
),
)
.toList(),
prefetchHooksCallback: ({assetId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins:
<
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic
>
>(state) {
if (assetId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.assetId,
referencedTable: i1
.$$AssetEditEntityTableReferences
._assetIdTable(db),
referencedColumn: i1
.$$AssetEditEntityTableReferences
._assetIdTable(db)
.id,
)
as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
),
);
}
typedef $$AssetEditEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$AssetEditEntityTable,
i1.AssetEditEntityData,
i1.$$AssetEditEntityTableFilterComposer,
i1.$$AssetEditEntityTableOrderingComposer,
i1.$$AssetEditEntityTableAnnotationComposer,
$$AssetEditEntityTableCreateCompanionBuilder,
$$AssetEditEntityTableUpdateCompanionBuilder,
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
i1.AssetEditEntityData,
i0.PrefetchHooks Function({bool assetId})
>;
i0.Index get idxAssetEditAssetId => i0.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
class $AssetEditEntityTable extends i4.AssetEditEntity
with i0.TableInfo<$AssetEditEntityTable, i1.AssetEditEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$AssetEditEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
@override
late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
'id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
'assetId',
);
@override
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
'asset_id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
),
);
@override
late final i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int>
action =
i0.GeneratedColumn<int>(
'action',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
).withConverter<i2.AssetEditAction>(
i1.$AssetEditEntityTable.$converteraction,
);
@override
late final i0.GeneratedColumnWithTypeConverter<
Map<String, Object?>,
i3.Uint8List
>
parameters =
i0.GeneratedColumn<i3.Uint8List>(
'parameters',
aliasedName,
false,
type: i0.DriftSqlType.blob,
requiredDuringInsert: true,
).withConverter<Map<String, Object?>>(
i1.$AssetEditEntityTable.$converterparameters,
);
static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta(
'sequence',
);
@override
late final i0.GeneratedColumn<int> sequence = i0.GeneratedColumn<int>(
'sequence',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<i0.GeneratedColumn> get $columns => [
id,
assetId,
action,
parameters,
sequence,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'asset_edit_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.AssetEditEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('asset_id')) {
context.handle(
_assetIdMeta,
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
);
} else if (isInserting) {
context.missing(_assetIdMeta);
}
if (data.containsKey('sequence')) {
context.handle(
_sequenceMeta,
sequence.isAcceptableOrUnknown(data['sequence']!, _sequenceMeta),
);
} else if (isInserting) {
context.missing(_sequenceMeta);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.AssetEditEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.AssetEditEntityData(
id: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
assetId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}asset_id'],
)!,
action: i1.$AssetEditEntityTable.$converteraction.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}action'],
)!,
),
parameters: i1.$AssetEditEntityTable.$converterparameters.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.blob,
data['${effectivePrefix}parameters'],
)!,
),
sequence: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}sequence'],
)!,
);
}
@override
$AssetEditEntityTable createAlias(String alias) {
return $AssetEditEntityTable(attachedDatabase, alias);
}
static i0.JsonTypeConverter2<i2.AssetEditAction, int, int> $converteraction =
const i0.EnumIndexConverter<i2.AssetEditAction>(
i2.AssetEditAction.values,
);
static i0.JsonTypeConverter2<Map<String, Object?>, i3.Uint8List, Object?>
$converterparameters = i4.editParameterConverter;
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class AssetEditEntityData extends i0.DataClass
implements i0.Insertable<i1.AssetEditEntityData> {
final String id;
final String assetId;
final i2.AssetEditAction action;
final Map<String, Object?> parameters;
final int sequence;
const AssetEditEntityData({
required this.id,
required this.assetId,
required this.action,
required this.parameters,
required this.sequence,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<String>(id);
map['asset_id'] = i0.Variable<String>(assetId);
{
map['action'] = i0.Variable<int>(
i1.$AssetEditEntityTable.$converteraction.toSql(action),
);
}
{
map['parameters'] = i0.Variable<i3.Uint8List>(
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters),
);
}
map['sequence'] = i0.Variable<int>(sequence);
return map;
}
factory AssetEditEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return AssetEditEntityData(
id: serializer.fromJson<String>(json['id']),
assetId: serializer.fromJson<String>(json['assetId']),
action: i1.$AssetEditEntityTable.$converteraction.fromJson(
serializer.fromJson<int>(json['action']),
),
parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson(
serializer.fromJson<Object?>(json['parameters']),
),
sequence: serializer.fromJson<int>(json['sequence']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'assetId': serializer.toJson<String>(assetId),
'action': serializer.toJson<int>(
i1.$AssetEditEntityTable.$converteraction.toJson(action),
),
'parameters': serializer.toJson<Object?>(
i1.$AssetEditEntityTable.$converterparameters.toJson(parameters),
),
'sequence': serializer.toJson<int>(sequence),
};
}
i1.AssetEditEntityData copyWith({
String? id,
String? assetId,
i2.AssetEditAction? action,
Map<String, Object?>? parameters,
int? sequence,
}) => i1.AssetEditEntityData(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
action: action ?? this.action,
parameters: parameters ?? this.parameters,
sequence: sequence ?? this.sequence,
);
AssetEditEntityData copyWithCompanion(i1.AssetEditEntityCompanion data) {
return AssetEditEntityData(
id: data.id.present ? data.id.value : this.id,
assetId: data.assetId.present ? data.assetId.value : this.assetId,
action: data.action.present ? data.action.value : this.action,
parameters: data.parameters.present
? data.parameters.value
: this.parameters,
sequence: data.sequence.present ? data.sequence.value : this.sequence,
);
}
@override
String toString() {
return (StringBuffer('AssetEditEntityData(')
..write('id: $id, ')
..write('assetId: $assetId, ')
..write('action: $action, ')
..write('parameters: $parameters, ')
..write('sequence: $sequence')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, assetId, action, parameters, sequence);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.AssetEditEntityData &&
other.id == this.id &&
other.assetId == this.assetId &&
other.action == this.action &&
other.parameters == this.parameters &&
other.sequence == this.sequence);
}
class AssetEditEntityCompanion
extends i0.UpdateCompanion<i1.AssetEditEntityData> {
final i0.Value<String> id;
final i0.Value<String> assetId;
final i0.Value<i2.AssetEditAction> action;
final i0.Value<Map<String, Object?>> parameters;
final i0.Value<int> sequence;
const AssetEditEntityCompanion({
this.id = const i0.Value.absent(),
this.assetId = const i0.Value.absent(),
this.action = const i0.Value.absent(),
this.parameters = const i0.Value.absent(),
this.sequence = const i0.Value.absent(),
});
AssetEditEntityCompanion.insert({
required String id,
required String assetId,
required i2.AssetEditAction action,
required Map<String, Object?> parameters,
required int sequence,
}) : id = i0.Value(id),
assetId = i0.Value(assetId),
action = i0.Value(action),
parameters = i0.Value(parameters),
sequence = i0.Value(sequence);
static i0.Insertable<i1.AssetEditEntityData> custom({
i0.Expression<String>? id,
i0.Expression<String>? assetId,
i0.Expression<int>? action,
i0.Expression<i3.Uint8List>? parameters,
i0.Expression<int>? sequence,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (assetId != null) 'asset_id': assetId,
if (action != null) 'action': action,
if (parameters != null) 'parameters': parameters,
if (sequence != null) 'sequence': sequence,
});
}
i1.AssetEditEntityCompanion copyWith({
i0.Value<String>? id,
i0.Value<String>? assetId,
i0.Value<i2.AssetEditAction>? action,
i0.Value<Map<String, Object?>>? parameters,
i0.Value<int>? sequence,
}) {
return i1.AssetEditEntityCompanion(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
action: action ?? this.action,
parameters: parameters ?? this.parameters,
sequence: sequence ?? this.sequence,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<String>(id.value);
}
if (assetId.present) {
map['asset_id'] = i0.Variable<String>(assetId.value);
}
if (action.present) {
map['action'] = i0.Variable<int>(
i1.$AssetEditEntityTable.$converteraction.toSql(action.value),
);
}
if (parameters.present) {
map['parameters'] = i0.Variable<i3.Uint8List>(
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value),
);
}
if (sequence.present) {
map['sequence'] = i0.Variable<int>(sequence.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('AssetEditEntityCompanion(')
..write('id: $id, ')
..write('assetId: $assetId, ')
..write('action: $action, ')
..write('parameters: $parameters, ')
..write('sequence: $sequence')
..write(')'))
.toString();
}
}

View File

@@ -2,9 +2,8 @@ part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest {
final String uri;
final Map<String, String> headers;
RemoteImageRequest({required this.uri, required this.headers});
RemoteImageRequest({required this.uri});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@@ -12,7 +11,7 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false);
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
// Android always returns encoded data, so we need to check for both shapes of the response.
final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
@@ -29,7 +28,7 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true);
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true);
if (info == null) return null;
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);

View File

@@ -4,6 +4,7 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
@@ -24,11 +25,10 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:isar/isar.dart' hide Index;
import 'db.repository.drift.dart';
// #zoneTxn is the symbol used by Isar to mark a transaction within the current zone
// ref: isar/isar_common.dart
const Symbol _kzoneTxn = #zoneTxn;
@@ -66,6 +66,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
AssetFaceEntity,
StoreEntity,
TrashedLocalAssetEntity,
AssetEditEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -97,7 +98,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 21;
int get schemaVersion => 22;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -234,6 +235,10 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
},
from21To22: (m, v22) async {
await m.createTable(v22.assetEditEntity);
await m.createIndex(v22.idxAssetEditAssetId);
},
),
);

View File

@@ -41,9 +41,11 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i19;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
as i20;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i21;
import 'package:drift/internal/modular.dart' as i22;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i22;
import 'package:drift/internal/modular.dart' as i23;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -85,9 +87,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
.$TrashedLocalAssetEntityTable(this);
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
late final i21.$AssetEditEntityTable assetEditEntity = i21
.$AssetEditEntityTable(this);
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
this,
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -125,6 +129,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i12.idxRemoteAlbumAssetAlbumAsset,
@@ -134,6 +139,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i18.idxAssetFaceAssetId,
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
];
@override
i0.StreamQueryUpdateRules
@@ -325,6 +331,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
),
result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
),
]);
@override
i0.DriftDatabaseOptions get options =>
@@ -384,4 +397,6 @@ class $DriftManager {
_db,
_db.trashedLocalAssetEntity,
);
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
}

View File

@@ -9489,6 +9489,565 @@ class Shape31 extends i0.VersionedTable {
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
}
final class Schema22 extends i0.VersionedSchema {
Schema22({required super.database}) : super(version: 22);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxRemoteAlbumOwnerId,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetLocalDateTimeDay,
idxRemoteAssetLocalDateTimeMonth,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
];
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 Shape28 remoteAssetEntity = Shape28(
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,
_column_101,
],
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 Shape30 localAssetEntity = Shape30(
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,
_column_98,
_column_96,
_column_46,
_column_47,
_column_103,
],
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 idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
'idx_remote_album_owner_id',
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
);
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 idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
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)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
'idx_remote_asset_local_date_time_day',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
);
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
'idx_remote_asset_local_date_time_month',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
);
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 Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
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 Shape29 assetFaceEntity = Shape29(
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,
_column_102,
_column_18,
],
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 Shape31 trashedLocalAssetEntity = Shape31(
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,
_column_97,
_column_103,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_36, _column_104, _column_105, _column_106],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
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 idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
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)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
}
class Shape32 extends i0.VersionedTable {
Shape32({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get action =>
columnsByName['action']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<i2.Uint8List> get parameters =>
columnsByName['parameters']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<int> get sequence =>
columnsByName['sequence']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_104(String aliasedName) =>
i1.GeneratedColumn<int>(
'action',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i1.GeneratedColumn<i2.Uint8List> _column_105(String aliasedName) =>
i1.GeneratedColumn<i2.Uint8List>(
'parameters',
aliasedName,
false,
type: i1.DriftSqlType.blob,
);
i1.GeneratedColumn<int> _column_106(String aliasedName) =>
i1.GeneratedColumn<int>(
'sequence',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -9510,6 +10069,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -9613,6 +10173,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from20To21(migrator, schema);
return 21;
case 21:
final schema = Schema22(database: database);
final migrator = i1.Migrator(database, schema);
await from21To22(migrator, schema);
return 22;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -9640,6 +10205,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -9662,5 +10228,6 @@ i1.OnUpgrade stepByStep({
from18To19: from18To19,
from19To20: from19To20,
from20To21: from20To21,
from21To22: from21To22,
),
);

View File

@@ -2,8 +2,7 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'logger_db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
@DriftDatabase(tables: [LogMessageEntity])
class DriftLogger extends $DriftLogger implements IDatabaseRepository {

View File

@@ -1,67 +1,55 @@
import 'dart:ffi';
import 'dart:io';
import 'package:cronet_http/cronet_http.dart';
import 'package:cupertino_http/cupertino_http.dart';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:path_provider/path_provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:ok_http/ok_http.dart';
import 'package:web_socket/web_socket.dart';
class NetworkRepository {
static late Directory _cachePath;
static late String _userAgent;
static final _clients = <String, http.Client>{};
static http.Client? _client;
static Pointer<Void>? _clientPointer;
static Future<void> init() {
return (
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
getUserAgentString().then((userAgent) => _userAgent = userAgent),
).wait;
static Future<void> init() async {
final clientPointer = Pointer<Void>.fromAddress(await networkApi.getClientPointer());
if (clientPointer == _clientPointer) {
return;
}
_clientPointer = clientPointer;
_client?.close();
if (Platform.isIOS) {
final session = URLSession.fromRawPointer(clientPointer.cast());
_client = CupertinoClient.fromSharedSession(session);
} else {
_client = OkHttpClient.fromJniGlobalRef(clientPointer);
}
}
static void reset() {
Future.microtask(init);
for (final client in _clients.values) {
client.close();
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls, {String? token}) async {
await networkApi.setRequestHeaders(headers, serverUrls, token);
if (Platform.isIOS) {
await init();
}
}
// ignore: avoid-unused-parameters
static Future<WebSocket> createWebSocket(Uri uri, {Map<String, String>? headers, Iterable<String>? protocols}) {
if (Platform.isIOS) {
final session = URLSession.fromRawPointer(_clientPointer!.cast());
return CupertinoWebSocket.connectWithSession(session, uri, protocols: protocols);
} else {
return OkHttpWebSocket.connectFromJniGlobalRef(_clientPointer!, uri, protocols: protocols);
}
_clients.clear();
}
const NetworkRepository();
/// Note: when disk caching is enabled, only one client may use a given directory at a time.
/// Different isolates or engines must use different directories.
http.Client getHttpClient(
String directoryName, {
CacheMode cacheMode = CacheMode.memory,
int diskCapacity = 0,
int maxConnections = 6,
int memoryCapacity = 10 << 20,
}) {
final cachedClient = _clients[directoryName];
if (cachedClient != null) {
return cachedClient;
}
final directory = Directory('${_cachePath.path}/$directoryName');
directory.createSync(recursive: true);
if (Platform.isAndroid) {
final engine = CronetEngine.build(
cacheMode: cacheMode,
cacheMaxSize: diskCapacity,
storagePath: directory.path,
userAgent: _userAgent,
);
return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true);
}
final config = URLSessionConfiguration.defaultSessionConfiguration()
..httpMaximumConnectionsPerHost = maxConnections
..cache = URLCache.withCapacity(
diskCapacity: diskCapacity,
memoryCapacity: memoryCapacity,
directory: directory.uri,
)
..httpAdditionalHeaders = {'User-Agent': _userAgent};
return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config);
}
/// Returns a shared HTTP client that uses native SSL configuration.
///
/// On iOS: Uses SharedURLSessionManager's URLSession.
/// On Android: Uses SharedHttpClientManager's OkHttpClient.
///
/// Must call [init] before using this method.
static http.Client get client => _client!;
}

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart';
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/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
@@ -32,15 +33,11 @@ class SyncApiRepository {
http.Client? httpClient,
}) async {
final stopwatch = Stopwatch()..start();
final client = httpClient ?? http.Client();
final client = httpClient ?? NetworkRepository.client;
final endpoint = "${_api.apiClient.basePath}/sync/stream";
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
final headerParams = <String, String>{};
await _api.applyToParams([], headerParams);
headers.addAll(headerParams);
final shouldReset = Store.get(StoreKey.shouldResetSync, false);
final request = http.Request('POST', Uri.parse(endpoint));
request.headers.addAll(headers);
@@ -51,6 +48,7 @@ class SyncApiRepository {
SyncRequestType.usersV1,
SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1,
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1,
@@ -119,8 +117,6 @@ class SyncApiRepository {
}
} catch (error, stack) {
return Future.error(error, stack);
} finally {
client.close();
}
stopwatch.stop();
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
@@ -156,6 +152,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson,
SyncEntityType.assetEditDeleteV1: SyncAssetEditDeleteV1.fromJson,
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,

View File

@@ -5,9 +5,11 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.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/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
@@ -26,8 +28,8 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction;
import 'package:openapi/api.dart' hide AlbumUserRole, UserMetadataKey, AssetEditAction, AssetVisibility;
class SyncStreamRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftSyncStreamRepository');
@@ -58,6 +60,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.userEntity.deleteAll();
await _db.userMetadataEntity.deleteAll();
await _db.remoteAssetCloudIdEntity.deleteAll();
await _db.assetEditEntity.deleteAll();
});
await _db.customStatement('PRAGMA foreign_keys = ON');
});
@@ -322,6 +325,63 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
Future<void> updateAssetEditsV1(Iterable<SyncAssetEditV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final edit in data) {
final companion = AssetEditEntityCompanion(
id: Value(edit.id),
assetId: Value(edit.assetId),
action: Value(edit.action.toAssetEditAction()),
parameters: Value(edit.parameters as Map<String, Object?>),
sequence: Value(edit.sequence),
);
batch.insert(_db.assetEditEntity, companion, onConflict: DoUpdate((_) => companion));
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetEditsV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> replaceAssetEditsV1(String assetId, Iterable<SyncAssetEditV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId));
for (final edit in data) {
final companion = AssetEditEntityCompanion(
id: Value(edit.id),
assetId: Value(edit.assetId),
action: Value(edit.action.toAssetEditAction()),
parameters: Value(edit.parameters as Map<String, Object?>),
sequence: Value(edit.sequence),
);
batch.insert(_db.assetEditEntity, companion);
}
});
} catch (error, stack) {
_logger.severe('Error: replaceAssetEditsV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> deleteAssetEditsV1(Iterable<SyncAssetEditDeleteV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final edit in data) {
batch.deleteWhere(_db.assetEditEntity, (row) => row.id.equals(edit.editId));
}
});
} catch (error, stack) {
_logger.severe('Error: deleteAssetEditsV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try {
await _db.batch((batch) {
@@ -798,3 +858,12 @@ extension on String {
extension on UserAvatarColor {
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value);
}
extension on api.AssetEditAction {
AssetEditAction toAssetEditAction() => switch (this) {
api.AssetEditAction.crop => AssetEditAction.crop,
api.AssetEditAction.rotate => AssetEditAction.rotate,
api.AssetEditAction.mirror => AssetEditAction.mirror,
_ => AssetEditAction.other,
};
}

View File

@@ -276,6 +276,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: origin,
);
TimelineQuery fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin origin) =>
(
bucketSource: () async* {
yield _generateBuckets(getAssets().length);
yield* assetCount.map(_generateBuckets);
},
assetSource: (offset, count) {
final assets = getAssets();
return Future.value(assets.skip(offset).take(count).toList(growable: false));
},
origin: origin,
);
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
// Sort assets by date descending and group by day
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));

View File

@@ -40,7 +40,6 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/wm_executor.dart';
@@ -60,7 +59,6 @@ void main() async {
// Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
runApp(
ProviderScope(
@@ -246,7 +244,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
@override
void reassemble() {
if (kDebugMode) {
NetworkRepository.reset();
NetworkRepository.init();
}
super.reassemble();
}

View File

@@ -1,6 +1,5 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
@@ -21,7 +20,6 @@ class BackUpState {
final DateTime progressInFileSpeedUpdateTime;
final int progressInFileSpeedUpdateSentBytes;
final double iCloudDownloadProgress;
final CancellationToken cancelToken;
final ServerDiskInfo serverInfo;
final bool autoBackup;
final bool backgroundBackup;
@@ -53,7 +51,6 @@ class BackUpState {
required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes,
required this.iCloudDownloadProgress,
required this.cancelToken,
required this.serverInfo,
required this.autoBackup,
required this.backgroundBackup,
@@ -78,7 +75,6 @@ class BackUpState {
DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes,
double? iCloudDownloadProgress,
CancellationToken? cancelToken,
ServerDiskInfo? serverInfo,
bool? autoBackup,
bool? backgroundBackup,
@@ -102,7 +98,6 @@ class BackUpState {
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
autoBackup: autoBackup ?? this.autoBackup,
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
@@ -120,7 +115,7 @@ class BackUpState {
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
}
@override
@@ -137,7 +132,6 @@ class BackUpState {
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
other.iCloudDownloadProgress == iCloudDownloadProgress &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo &&
other.autoBackup == autoBackup &&
other.backgroundBackup == backgroundBackup &&
@@ -163,7 +157,6 @@ class BackUpState {
progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^
iCloudDownloadProgress.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
autoBackup.hashCode ^
backgroundBackup.hashCode ^

View File

@@ -1,11 +1,8 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
class ManualUploadState {
final CancellationToken cancelToken;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
final int currentAssetIndex;
@@ -29,7 +26,6 @@ class ManualUploadState {
required this.progressInFileSpeeds,
required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes,
required this.cancelToken,
required this.currentUploadAsset,
required this.totalAssetsToUpload,
required this.currentAssetIndex,
@@ -44,7 +40,6 @@ class ManualUploadState {
List<double>? progressInFileSpeeds,
DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes,
CancellationToken? cancelToken,
CurrentUploadAsset? currentUploadAsset,
int? totalAssetsToUpload,
int? successfulUploads,
@@ -58,7 +53,6 @@ class ManualUploadState {
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
cancelToken: cancelToken ?? this.cancelToken,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload,
currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex,
@@ -69,7 +63,7 @@ class ManualUploadState {
@override
String toString() {
return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)';
return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)';
}
@override
@@ -84,7 +78,6 @@ class ManualUploadState {
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
other.cancelToken == cancelToken &&
other.currentUploadAsset == currentUploadAsset &&
other.totalAssetsToUpload == totalAssetsToUpload &&
other.currentAssetIndex == currentAssetIndex &&
@@ -100,7 +93,6 @@ class ManualUploadState {
progressInFileSpeeds.hashCode ^
progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^
cancelToken.hashCode ^
currentUploadAsset.hashCode ^
totalAssetsToUpload.hashCode ^
currentAssetIndex.hashCode ^

View File

@@ -96,10 +96,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
await backupNotifier.startForegroundBackup(currentUser.id);
}
Future<void> stopBackup() async {
await backupNotifier.stopForegroundBackup();
}
return Scaffold(
appBar: AppBar(
elevation: 0,
@@ -136,9 +132,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
const Divider(),
BackupToggleButton(
onStart: () async => await startBackup(),
onStop: () async {
onStop: () {
syncSuccess = null;
await stopBackup();
backupNotifier.stopForegroundBackup();
},
),
switch (error) {
@@ -152,10 +148,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
children: [
Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1),
const SizedBox(width: 8),
Text(
context.t.backup_error_sync_failed,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
textAlign: TextAlign.center,
Flexible(
child: Text(
context.t.backup_error_sync_failed,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
textAlign: TextAlign.center,
),
),
],
),
@@ -348,6 +346,7 @@ class _RemainderCard extends ConsumerWidget {
remainderCount.toString(),
style: context.textTheme.titleLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
fontFeatures: [const FontFeature.tabularFigures()],
),
),
if (syncStatus.isRemoteSyncing)
@@ -487,6 +486,7 @@ class _PreparingStatusState extends ConsumerState {
style: context.textTheme.titleMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w600,
fontFeatures: [const FontFeature.tabularFigures()],
),
),
],
@@ -511,6 +511,7 @@ class _PreparingStatusState extends ConsumerState {
style: context.textTheme.titleMedium?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.w600,
fontFeatures: [const FontFeature.tabularFigures()],
),
),
],

View File

@@ -112,16 +112,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
// Waits for hashing to be cancelled before starting a new one
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
if (isBackupEnabled) {
backupNotifier.stopForegroundBackup();
unawaited(
backupNotifier.stopForegroundBackup().whenComplete(
() => backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(user.id);
} else {
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
}
}),
),
backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(user.id);
} else {
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
}
}),
);
}
}

View File

@@ -59,16 +59,15 @@ class DriftBackupOptionsPage extends ConsumerWidget {
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundSync = ref.read(backgroundSyncProvider);
backupNotifier.stopForegroundBackup();
unawaited(
backupNotifier.stopForegroundBackup().whenComplete(
() => backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(currentUser.id);
} else {
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
}
}),
),
backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(currentUser.id);
} else {
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
}
}),
);
}
},

View File

@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
class SettingsHeader {
String key = "";
@@ -20,7 +21,6 @@ class HeaderSettingsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// final apiService = ref.watch(apiServiceProvider);
final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false);
@@ -75,7 +75,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
],
),
body: PopScope(
onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value),
onPopInvokedWithResult: (didPop, _) => saveHeaders(ref, headers.value),
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
itemCount: list.length,
@@ -87,7 +87,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
);
}
saveHeaders(List<SettingsHeader> headers) {
saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
final headersMap = {};
for (var header in headers) {
final key = header.key.trim();
@@ -98,7 +98,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
}
var encoded = jsonEncode(headersMap);
Store.put(StoreKey.customHeaders, encoded);
await Store.put(StoreKey.customHeaders, encoded);
await ref.read(apiServiceProvider).updateHeaders();
}
}

View File

@@ -7,11 +7,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/editing/edit.page.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'edit.page.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to crop an image and then navigate to the [EditImagePage] with the

View File

@@ -179,7 +179,7 @@ class NetworkApi {
}
}
Future<ClientCertData> selectCertificate(ClientCertPrompt promptText) async {
Future<void> selectCertificate(ClientCertPrompt promptText) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -197,13 +197,8 @@ class NetworkApi {
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 ClientCertData?)!;
return;
}
}
@@ -229,4 +224,83 @@ class NetworkApi {
return;
}
}
Future<bool> hasCertificate() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$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 bool?)!;
}
}
Future<int> getClientPointer() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$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 int?)!;
}
}
Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls, token]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
}

View File

@@ -49,12 +49,7 @@ class RemoteImageApi {
final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
}) async {
Future<Map<String, int>?> requestImage(String url, {required int requestId, required bool preferEncoded}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -62,12 +57,7 @@ class RemoteImageApi {
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
url,
headers,
requestId,
preferEncoded,
]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, requestId, preferEncoded]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -62,7 +61,7 @@ class DriftEditImagePage extends ConsumerWidget {
return;
}
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken());
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,

View File

@@ -80,51 +80,28 @@ class DriftSearchPage extends HookConsumerWidget {
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final userPreferences = ref.watch(userMetadataPreferencesProvider);
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(message, style: context.textTheme.labelLarge),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: context.colorScheme.onSurface,
);
}
searchFilter(SearchFilter filter) async {
if (filter.isEmpty) {
return;
}
searchFilter(SearchFilter filter) {
if (preFilter == null && filter == previousFilter.value) {
return;
}
isSearching.value = true;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter);
ref.read(paginatedSearchProvider.notifier).clear();
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context)));
if (filter.isEmpty) {
previousFilter.value = null;
return;
}
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter;
isSearching.value = false;
}
search() => searchFilter(filter.value);
loadMoreSearchResult() async {
isSearching.value = true;
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context)));
}
isSearching.value = false;
loadMoreSearchResults() {
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value));
}
searchPreFilter() {
@@ -742,10 +719,10 @@ class DriftSearchPage extends HookConsumerWidget {
),
),
),
if (isSearching.value)
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
if (filter.value.isEmpty)
const _SearchSuggestions()
else
_SearchResultGrid(onScrollEnd: loadMoreSearchResult),
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),
],
),
);
@@ -757,45 +734,85 @@ class _SearchResultGrid extends ConsumerWidget {
const _SearchResultGrid({required this.onScrollEnd});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets));
bool _onScrollUpdateNotification(ScrollNotification notification) {
final metrics = notification.metrics;
if (assets.isEmpty) {
return const _SearchEmptyContent();
if (metrics.axis != Axis.vertical) return false;
final isBottomSheet = notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final remaining = metrics.maxScrollExtent - metrics.pixels;
if (remaining < metrics.viewportDimension && !isBottomSheet) {
onScrollEnd();
}
return NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
return false;
}
final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical;
Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
onScrollEnd();
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent);
}
if (isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
);
}
return true;
},
final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null));
if (hasMore) return null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
'search_no_more_result'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty));
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
if (!hasAssets && !isLoading) {
return const _SearchNoResults();
}
return NotificationListener<ScrollUpdateNotification>(
onNotification: _onScrollUpdateNotification,
child: SliverFillRemaining(
child: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search);
ref.onDispose(timelineService.dispose);
return timelineService;
final notifier = ref.read(paginatedSearchProvider.notifier);
final service = ref
.watch(timelineFactoryProvider)
.fromAssetStream(
() => ref.read(paginatedSearchProvider).assets,
notifier.assetCount,
TimelineOrigin.search,
);
ref.onDispose(service.dispose);
return service;
}),
],
child: Timeline(
key: ValueKey(assets.length),
groupBy: GroupAssetsBy.none,
appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false,
initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)),
loadingWidget: const SizedBox.shrink(),
bottomSliverWidget: _bottomWidget(context, ref),
),
),
),
@@ -803,8 +820,35 @@ class _SearchResultGrid extends ConsumerWidget {
}
}
class _SearchEmptyContent extends StatelessWidget {
const _SearchEmptyContent();
class _SearchNoResults extends StatelessWidget {
const _SearchNoResults();
@override
Widget build(BuildContext context) {
return SliverFillRemaining(
hasScrollBody: false,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search_off_rounded, size: 72, color: context.colorScheme.onSurfaceVariant),
const SizedBox(height: 24),
Text(
'search_no_result'.t(context: context),
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
],
),
),
);
}
}
class _SearchSuggestions extends StatelessWidget {
const _SearchSuggestions();
@override
Widget build(BuildContext context) {

View File

@@ -1,5 +1,7 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
@@ -21,40 +23,52 @@ class SearchFilterProvider extends Notifier<SearchFilter?> {
}
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
class SearchState {
final List<BaseAsset> assets;
final int? nextPage;
final bool isLoading;
const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false});
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final SearchService _searchService;
final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1));
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
Future<bool> search(SearchFilter filter) async {
if (state.nextPage == null) {
return false;
}
Stream<int> get assetCount => _assetCountController.stream;
Future<void> search(SearchFilter filter) async {
if (state.nextPage == null || state.isLoading) return;
state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true);
final result = await _searchService.search(filter, state.nextPage!);
if (result == null) {
return false;
state = SearchState(assets: state.assets, nextPage: state.nextPage);
return;
}
state = SearchResult(
assets: [...state.assets, ...result.assets],
nextPage: result.nextPage,
scrollOffset: state.scrollOffset,
);
final assets = [...state.assets, ...result.assets];
state = SearchState(assets: assets, nextPage: result.nextPage);
return true;
_assetCountController.add(assets.length);
}
void setScrollOffset(double offset) {
state = state.copyWith(scrollOffset: offset);
void clear() {
state = const SearchState();
_assetCountController.add(0);
}
clear() {
state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0);
@override
void dispose() {
_assetCountController.close();
super.dispose();
}
}

View File

@@ -101,7 +101,8 @@ class _UploadProgressDialog extends ConsumerWidget {
actions: [
ImmichTextButton(
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.cancel();
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
Navigator.of(context).pop();
},
labelText: 'cancel'.t(context: context),

View File

@@ -19,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
@@ -50,8 +49,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
bool _showingDetails = false;
bool _isZoomed = false;
final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
final _scrollController = SnapScrollController();
double _snapOffset = 0.0;
DragStartDetails? _dragStart;
@@ -63,17 +61,17 @@ class _AssetPageState extends ConsumerState<AssetPage> {
super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_proxyScrollController.hasClients) return;
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
if (!mounted || !_scrollController.hasClients) return;
_scrollController.snapPosition.snapOffset = _snapOffset;
if (_showingDetails && _snapOffset > 0) {
_proxyScrollController.jumpTo(_snapOffset);
_scrollController.jumpTo(_snapOffset);
}
});
}
@override
void dispose() {
_proxyScrollController.dispose();
_scrollController.dispose();
_scaleBoundarySub?.cancel();
_eventSubscription?.cancel();
super.dispose();
@@ -88,21 +86,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _showDetails() {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
if (!_scrollController.hasClients || _snapOffset <= 0) return;
_viewer.setShowingDetails(true);
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
_scrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
}
bool _willClose(double scrollVelocity) {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
final position = _proxyScrollController.position;
return _proxyScrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
}
bool _willClose(double scrollVelocity) =>
_scrollController.hasClients &&
_snapOffset > 0 &&
_scrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) <
SnapScrollPhysics.minSnapDistance;
void _syncShowingDetails() {
final offset = _proxyScrollController.offset;
final offset = _scrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true);
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
@@ -124,8 +121,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _startProxyDrag() {
if (_proxyScrollController.hasClients && _dragStart != null) {
_drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
if (_scrollController.hasClients && _dragStart != null) {
_drag = _scrollController.position.drag(_dragStart!, () => _drag = null);
}
}
@@ -250,11 +247,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false);
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
return;
}
@@ -294,7 +286,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent,
required bool isPlayingMotionVideo,
required BoxDecoration backgroundDecoration,
}) {
final size = context.sizeData;
@@ -305,7 +296,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
backgroundDecoration: backgroundDecoration,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
tightMode: true,
@@ -347,7 +337,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
tightMode: true,
onPageBuild: _onPageBuild,
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer(
key: _NativeVideoViewerKey(asset.heroTag),
asset: asset,
@@ -390,57 +379,52 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_snapOffset = detailsOffset - snapTarget;
if (_proxyScrollController.hasClients) {
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
if (_scrollController.hasClients) {
_scrollController.snapPosition.snapOffset = _snapOffset;
}
return Stack(
children: [
Offstage(
child: SingleChildScrollView(
controller: _proxyScrollController,
physics: const SnapScrollPhysics(),
child: const SizedBox.shrink(),
),
),
SingleChildScrollView(
controller: _scrollController,
physics: const NeverScrollableScrollPhysics(),
child: Stack(
children: [
SizedBox(
width: viewportWidth,
height: viewportHeight,
child: _buildPhotoView(
asset: displayAsset,
heroAttributes: isCurrent
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
: null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
physics: const SnapScrollPhysics(),
child: ColoredBox(
color: _showingDetails ? Colors.black : Colors.transparent,
child: Stack(
children: [
SizedBox(
width: viewportWidth,
height: viewportHeight,
child: _buildPhotoView(
asset: displayAsset,
heroAttributes: isCurrent
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
: null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
),
),
),
IgnorePointer(
ignoring: !_showingDetails,
child: Column(
children: [
SizedBox(height: detailsOffset),
GestureDetector(
onVerticalDragStart: _beginDrag,
onVerticalDragUpdate: _updateDrag,
onVerticalDragEnd: _endDrag,
onVerticalDragCancel: _onDragCancel,
child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2,
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
IgnorePointer(
ignoring: !_showingDetails,
child: Column(
children: [
SizedBox(height: detailsOffset),
GestureDetector(
onVerticalDragStart: _beginDrag,
onVerticalDragUpdate: _updateDrag,
onVerticalDragEnd: _endDrag,
onVerticalDragCancel: _onDragCancel,
child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2,
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
),
),
),
],
],
),
),
),
],
],
),
),
),
if (stackChildren != null && stackChildren.isNotEmpty)

View File

@@ -61,15 +61,27 @@ class ViewerBottomBar extends ConsumerWidget {
),
),
child: Container(
color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),
),
),
),

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
@@ -186,11 +185,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
final source = await _videoSource;
if (source == null || !mounted) return;
unawaited(
nc.loadVideoSource(source).catchError((error) {
_log.severe('Error loading video source: $error');
}),
);
await _notifier.load(source);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
@@ -213,21 +208,28 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
@override
Widget build(BuildContext context) {
// Prevent the provider from being disposed whilst the widget is alive.
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
return Stack(
children: [
Center(child: widget.image),
if (!isCasting)
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
],
return IgnorePointer(
child: Stack(
children: [
Center(child: widget.image),
if (!isCasting) ...[
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
Center(
child: AnimatedOpacity(
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0,
duration: const Duration(milliseconds: 400),
child: const CircularProgressIndicator(),
),
),
],
],
),
);
}
}

View File

@@ -1,114 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class VideoViewerControls extends HookConsumerWidget {
final BaseAsset asset;
final Duration hideTimerDuration;
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
@override
Widget build(BuildContext context, WidgetRef ref) {
final videoPlayerName = asset.heroTag;
final assetIsVideo = asset.isVideo;
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
final cast = ref.watch(castProvider);
// A timer to hide the controls
final hideTimer = useTimer(hideTimerDuration, () {
if (!context.mounted) {
return;
}
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
// Do not hide on paused
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(assetViewerProvider.notifier).setControls(true);
}
// When playback starts, reset the hide timer
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
if (cast.isCasting) {
switch (cast.castState) {
case CastState.playing:
ref.read(castProvider.notifier).pause();
case CastState.paused:
ref.read(castProvider.notifier).play();
default:
}
return;
}
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
switch (status) {
case VideoPlaybackStatus.playing:
notifier.pause();
case VideoPlaybackStatus.completed:
notifier.restart();
default:
notifier.play();
}
}
void toggleControlsVisibility() {
if (showBuffering) return;
if (showControls) {
ref.read(assetViewerProvider.notifier).setControls(false);
} else {
showControlsAndStartHideTimer();
}
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: toggleControlsVisibility,
child: IgnorePointer(
ignoring: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else
CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: status == VideoPlaybackStatus.completed,
isPlaying:
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
],
),
),
);
}
}

View File

@@ -75,17 +75,29 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: AppBar(
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: showingDetails || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions
: actions,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: showingDetails
? null
: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
child: AppBar(
backgroundColor: Colors.transparent,
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: showingDetails || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions
: actions,
),
),
),
);
@@ -101,17 +113,14 @@ class _AppBarBackButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
return Padding(
padding: const EdgeInsets.only(left: 12.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent,
shape: const CircleBorder(),
iconSize: 22,
iconColor: foregroundColor,
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white,
padding: EdgeInsets.zero,
elevation: showingDetails ? 4 : 0,
),

View File

@@ -0,0 +1,96 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show InformationCollector;
import 'package:flutter/painting.dart';
/// A [MultiFrameImageStreamCompleter] with support for listener tracking
/// which makes resource cleanup possible when no longer needed.
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once any image or the codec has been provided.
// Until then the image cache holds one listener, so "last real listener gone"
// is _listenerCount == 1, not 0.
bool didProvideImage = false;
AnimatedImageStreamCompleter._({
required super.codec,
required super.scale,
super.informationCollector,
void Function()? onLastListenerRemoved,
}) : _onLastListenerRemoved = onLastListenerRemoved;
factory AnimatedImageStreamCompleter({
required Stream<Object> stream,
required double scale,
ImageInfo? initialImage,
InformationCollector? informationCollector,
void Function()? onLastListenerRemoved,
}) {
final codecCompleter = Completer<ui.Codec>();
final self = AnimatedImageStreamCompleter._(
codec: codecCompleter.future,
scale: scale,
informationCollector: informationCollector,
onLastListenerRemoved: onLastListenerRemoved,
);
if (initialImage != null) {
self.didProvideImage = true;
self.setImage(initialImage);
}
stream.listen(
(item) {
if (item is ImageInfo) {
self.didProvideImage = true;
self.setImage(item);
} else if (item is ui.Codec) {
if (!codecCompleter.isCompleted) {
self.didProvideImage = true;
codecCompleter.complete(item);
}
}
},
onError: (Object error, StackTrace stack) {
if (!codecCompleter.isCompleted) {
codecCompleter.completeError(error, stack);
}
},
onDone: () {
// also complete if we are done but no error occurred, and we didn't call complete yet
// could happen on cancellation
if (!codecCompleter.isCompleted) {
codecCompleter.completeError(StateError('Stream closed without providing a codec'));
}
},
);
return self;
}
@override
void addListener(ImageStreamListener listener) {
super.addListener(listener);
_listenerCount++;
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount--;
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
if (onlyCacheListenerLeft || noListenersAfterCodec) {
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
}

View File

@@ -140,7 +140,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
} else {
final String assetId;
final String thumbhash;
@@ -153,7 +153,12 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type);
provider = RemoteFullImageProvider(
assetId: assetId,
thumbhash: thumbhash,
assetType: asset.type,
isAnimated: asset.isAnimatedImage,
);
}
return provider;

View File

@@ -1,11 +1,10 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -58,8 +57,9 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final String id;
final Size size;
final AssetType assetType;
final bool isAnimated;
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -68,6 +68,21 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
@override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
}
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
@@ -75,6 +90,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
@@ -110,15 +126,45 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
yield* loadRequest(request, decode);
}
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final previewRequest = request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,
);
yield* loadRequest(previewRequest, decode);
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
// always try original for animated, since previews don't support animation
final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType);
final codec = await loadCodecRequest(originalRequest);
if (codec == null) {
throw StateError('Failed to load animated codec for local asset ${key.id}');
}
yield codec;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is LocalFullImageProvider) {
return id == other.id && size == other.size;
return id == other.id && size == other.size && isAnimated == other.isAnimated;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode;
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
}

View File

@@ -4,9 +4,9 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -37,7 +37,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
}
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders());
final request = this.request = RemoteImageRequest(uri: key.url);
return loadRequest(request, decode);
}
@@ -59,8 +59,14 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final String assetId;
final String thumbhash;
final AssetType assetType;
final bool isAnimated;
RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
RemoteFullImageProvider({
required this.assetId,
required this.thumbhash,
required this.assetType,
required this.isAnimated,
});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -69,12 +75,27 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
@override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
}
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
@@ -88,10 +109,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return;
}
final headers = ApiService.getRequestHeaders();
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
);
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
@@ -105,20 +124,47 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return;
}
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
yield* loadRequest(originalRequest, decode);
}
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
);
yield* loadRequest(previewRequest, decode, evictOnError: false);
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
// always try original for animated, since previews don't support animation
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
final codec = await loadCodecRequest(originalRequest);
if (codec == null) {
throw StateError('Failed to load animated codec for asset ${key.assetId}');
}
yield codec;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode;
}

View File

@@ -305,6 +305,8 @@ class _AssetTypeIcons extends StatelessWidget {
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
),
if (asset.isAnimatedImage)
const Padding(padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.gif_rounded)),
],
);
}

View File

@@ -5,6 +5,7 @@ const Size kTimelineFixedTileExtent = Size.square(256);
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;
const double kScrubberThumbHeight = 48.0;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);

View File

@@ -530,12 +530,14 @@ class _CircularThumb extends StatelessWidget {
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(48.0),
bottomLeft: Radius.circular(48.0),
topLeft: Radius.circular(kScrubberThumbHeight),
bottomLeft: Radius.circular(kScrubberThumbHeight),
topRight: Radius.circular(4.0),
bottomRight: Radius.circular(4.0),
),
child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))),
child: Container(
constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)),
),
),
);
}

View File

@@ -17,6 +17,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
@@ -34,6 +35,7 @@ class Timeline extends StatelessWidget {
super.key,
this.topSliverWidget,
this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.showStorageIndicator = false,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
@@ -41,13 +43,14 @@ class Timeline extends StatelessWidget {
this.groupBy,
this.withScrubber = true,
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
this.persistentBottomBar = false,
this.loadingWidget,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final bool showStorageIndicator;
final Widget? appBar;
final Widget? bottomSheet;
@@ -55,9 +58,9 @@ class Timeline extends StatelessWidget {
final GroupAssetsBy? groupBy;
final bool withScrubber;
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
final bool persistentBottomBar;
final Widget? loadingWidget;
@override
Widget build(BuildContext context) {
@@ -82,13 +85,14 @@ class Timeline extends StatelessWidget {
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
initialScrollOffset: initialScrollOffset,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
),
),
),
@@ -111,24 +115,26 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,
this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.appBar,
this.bottomSheet,
this.withScrubber = true,
this.persistentBottomBar = false,
this.snapToMonth = true,
this.initialScrollOffset,
this.maxWidth,
this.loadingWidget,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final Widget? appBar;
final Widget? bottomSheet;
final bool withScrubber;
final bool persistentBottomBar;
final bool snapToMonth;
final double? initialScrollOffset;
final double? maxWidth;
final Widget? loadingWidget;
@override
ConsumerState createState() => _SliverTimelineState();
@@ -152,10 +158,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void initState() {
super.initState();
_scrollController = ScrollController(
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
onAttach: _restoreAssetPosition,
);
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
@@ -373,6 +376,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
},
child: asyncSegments.widgetWhen(
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
@@ -380,12 +384,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
: 0;
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0;
const bottomSheetOpenModifier = 120.0;
final bottomPadding =
context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
final grid = CustomScrollView(
primary: true,
@@ -408,7 +409,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false,
),
),
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
],
);
@@ -419,7 +421,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
bottomPadding: scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid,

View File

@@ -232,7 +232,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
}
Future<void> _performPause() async {
Future<void> _performPause() {
if (_ref.read(authProvider).isAuthenticated) {
if (!Store.isBetaTimelineEnabled) {
// Do not cancel backup if manual upload is in progress
@@ -240,15 +240,13 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_ref.read(backupProvider.notifier).cancelBackup();
}
} else {
await _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
}
_ref.read(websocketProvider.notifier).disconnect();
}
try {
await LogService.I.flush();
} catch (_) {}
return LogService.I.flush().catchError((_) {});
}
Future<void> handleAppDetached() async {

View File

@@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
return;
}
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
if (showing) {
final heroTag = state.currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
final heroTag = state.currentAsset?.heroTag;
if (heroTag != null) {
final notifier = ref.read(videoPlayerProvider(heroTag).notifier);
showing ? notifier.hold() : notifier.release();
}
}

View File

@@ -44,10 +44,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
NativeVideoPlayerController? _controller;
Timer? _bufferingTimer;
Timer? _seekTimer;
void attachController(NativeVideoPlayerController controller) {
_controller = controller;
}
VideoPlaybackStatus? _holdStatus;
@override
void dispose() {
@@ -59,6 +56,19 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
super.dispose();
}
void attachController(NativeVideoPlayerController controller) {
_controller = controller;
}
Future<void> load(VideoSource source) async {
_startBufferingTimer();
try {
await _controller?.loadVideoSource(source);
} catch (e) {
_log.severe('Error loading video source: $e');
}
}
Future<void> pause() async {
if (_controller == null) return;
@@ -94,16 +104,50 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
}
void seekTo(Duration position) {
if (_controller == null) return;
if (_controller == null || state.position == position) return;
state = state.copyWith(position: position);
_seekTimer?.cancel();
_seekTimer = Timer(const Duration(milliseconds: 100), () {
_controller?.seekTo(position.inMilliseconds);
if (_seekTimer?.isActive ?? false) return;
_seekTimer = Timer(const Duration(milliseconds: 150), () {
_controller?.seekTo(state.position.inMilliseconds);
});
}
void toggle() {
_holdStatus = null;
switch (state.status) {
case VideoPlaybackStatus.paused:
play();
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
pause();
case VideoPlaybackStatus.completed:
restart();
}
}
/// Pauses playback and preserves the current status for later restoration.
void hold() {
if (_holdStatus != null) return;
_holdStatus = state.status;
pause();
}
/// Restores playback to the status before [hold] was called.
void release() {
final status = _holdStatus;
_holdStatus = null;
switch (status) {
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
play();
default:
}
}
Future<void> restart() async {
seekTo(Duration.zero);
await play();
@@ -149,13 +193,12 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
final position = Duration(milliseconds: playbackInfo.position);
if (state.position == position) return;
if (state.status == VideoPlaybackStatus.buffering) {
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
} else {
state = state.copyWith(position: position);
}
if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer();
_startBufferingTimer();
state = state.copyWith(
position: position,
status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null,
);
}
void onNativeStatusChanged() {
@@ -173,9 +216,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
onNativePlaybackEnded();
}
if (state.status != newStatus) {
state = state.copyWith(status: newStatus);
}
if (state.status != newStatus) state = state.copyWith(status: newStatus);
}
void onNativePlaybackEnded() {
@@ -186,7 +227,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
void _startBufferingTimer() {
_bufferingTimer?.cancel();
_bufferingTimer = Timer(const Duration(seconds: 3), () {
if (mounted && state.status == VideoPlaybackStatus.playing) {
if (mounted && state.status != VideoPlaybackStatus.completed) {
state = state.copyWith(status: VideoPlaybackStatus.buffering);
}
});

View File

@@ -123,7 +123,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<bool> saveAuthInfo({required String accessToken}) async {
await _apiService.setAccessToken(accessToken);
await Store.put(StoreKey.accessToken, accessToken);
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final customHeaders = Store.tryGet(StoreKey.customHeaders);
@@ -144,7 +145,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
user = serverUser;
await Store.put(StoreKey.deviceId, deviceId);
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
await Store.put(StoreKey.accessToken, accessToken);
}
} on ApiException catch (error, stackTrace) {
if (error.code == 401) {

View File

@@ -1,4 +1,5 @@
import 'package:cancellation_token_http/http.dart';
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Tracks per-asset upload progress.
@@ -30,4 +31,4 @@ final assetUploadProgressProvider = NotifierProvider<AssetUploadProgressNotifier
AssetUploadProgressNotifier.new,
);
final manualUploadCancelTokenProvider = StateProvider<CancellationToken?>((ref) => null);
final manualUploadCancelTokenProvider = StateProvider<Completer<void>?>((ref) => null);

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