* feat(shared-link): enhance shared link UI and functionality with new expiry options and improved layout
* rebase & cleanup
---------
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
* add bulk_tag_assets_action_button to general_bottom_sheet.widget
include create tag tile in 'Add Tags' action modal
* follow provider -> svc -> repo pattern for tags
* rebase and cleanup
---------
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
* feat(mobile): slideshow view
* move slideshow settings to metadata store
* remove watch in initState
* wrap progress bar in safearea
* show slideshow button on remote albums
* fix crash on unknown assets
* always show slideshow option
* add zoom effect
* add padding to slideshow settings
* chore: styling tweak
---------
Co-authored-by: Alex <alex.tran1502@gmail.com>
When the user pops back from the asset viewer mid-flight, the hero
animation can fire its status listener after _ThumbnailTileState has
been disposed. setState then throws a null check on State._element.
Guard the listener with `if (!mounted) return;` — same pattern as
#28300 in the album sync action.
The hybrid added in onReadCompleted reuses Cronet's ByteBuffer between
reads to save a JNI wrap call when no grow is needed. That reuse breaks
advance() — Cronet's position() is cumulative across reads, so the same
K bytes get counted on every subsequent iteration. b.offset overshoots
b.capacity, the reuse branch keeps firing on a now-empty buffer, and
request.read() throws the original IllegalArgumentException again.
Always pass a fresh wrap from wrapRemaining() so byteBuffer.position()
reflects only this iteration's bytes. Same shape as the original PR
had before the broken optimization was layered on top.
CronetImageFetcher sized the response buffer from Content-Length, which is
the compressed wire size. Cronet auto-decompresses gzip/br responses and
writes decompressed bytes into the buffer, exceeding it and throwing
IllegalArgumentException: ByteBuffer is already full on the next read. Use
the growable path; Content-Length becomes an initial alloc hint only,
capped at 128 MB so an untrusted server can't overflow Int.MAX_VALUE or
OOM us upfront. Reuse Cronet's ByteBuffer between reads when no grow is
needed.
fix(mobile): use correct delete for trashed assets
When viewing a trashed asset, the viewer bottom bar now shows the permanent delete button instead of the trash button, which had no effect on already-trashed assets.
* fix(mobile): don't block app open on slow validateAccessToken
AuthGuard.onNavigation was async so auto_route awaited the body through validateAccessToken's OS timeout. now it's sync and the validate runs in bg. kicks to login on 401.
* fix(mobile): handle re-login race in AuthGuard validate
if user logs out + logs back in during a slow validate, the old 401 was logging them out again. now we check the token hasn't changed before redirecting, and dedupe in-flight calls.
---------
Co-authored-by: Alex <alex.tran1502@gmail.com>
* fix(mobile): clear linkedRemoteAlbumId in reset() so FK refs dont dangle
reset() runs with foreign_keys off before wiping remote_* tables, so the ON DELETE SET NULL cascade on linkedRemoteAlbumId doesnt fire. local rows keep pointing at deleted remote ids.
affects logout (clearLocalData calls reset()) and the server SyncResetV1 path (30 day idle, etc). after re-login, syncLinkedAlbum either silently warns or fires 400s (those are covered by #28299).
null the column manually inside the same transaction. cascade still works for normal SyncAlbumDeleteV1.
verified on pixel 9a with this branch built locally: logged out, deleted album from web, logged back in. without fix linkedRemoteAlbumId stayed dangling. with fix all three local rows have linkedRemoteAlbumId = NULL after the logout reset, and recovery is clean once manageLinkedAlbums runs again.
* fix(mobile): always re-enable foreign_keys in reset() + simplify the update
re-enable foreign_keys inside a try/finally so it always runs even if the transaction throws. without this, a failed reset would leave the connection with foreign_keys = OFF and silently disable cascades for everything after (per copilot review).
also drop the where filter on the linkedRemoteAlbumId update, unconditional update-all is simpler and we wipe everything in reset anyway (per ganka review).
server removed both fields from AssetMediaCreateDto in #27818. zod silently strips unknown fields so uploads still work, but we send dead weight on every request.
drop from foreground + background upload paths + share intent path. deviceAssetId stays as the internal background_downloader taskId, just not in the multipart form fields anymore.
* feat(ci): publish PR Android APK to R2 with installable links
Adds a universal debug APK to PR builds and uploads it to a public
R2 bucket alongside the existing GitHub Actions artifact. Posts a
sticky PR comment with tap-to-install links and a QR code so testers
can install directly on their device without unzipping artifacts.
Required setup:
- Secrets: R2_APK_ACCESS_KEY_ID, R2_APK_SECRET_ACCESS_KEY,
R2_APK_ACCOUNT_ID, R2_APK_BUCKET
- Optional repo variable: APK_PUBLIC_HOST (defaults to apk.immich.app)
- R2 bucket configured with a public custom domain matching APK_PUBLIC_HOST
* chore(ci): drop R2 upload, link directly to GitHub artifact
Surfaces the existing release-apk-signed artifact in a sticky PR
comment with a QR code. Avoids new infra and secrets — the trade-off
is GitHub login and a zip wrapper instead of tap-to-install.
* feat(ci): build PR APK as release and publish to GitHub Release
PR builds now produce a release APK signed with the release keystore.
The universal APK is published as a GitHub Release asset under tag
'pr-<num>' (prerelease), giving testers a direct, unzipped, tap-to-
install URL plus a QR code in the PR comment. The release-apk-signed
artifact is unchanged.
* chore(ci): drop GitHub Release, publish universal APK as own artifact
Reverts the prerelease publish. Uploads the universal release APK as
a separate single-file artifact so its download URL gives a zip
containing only that APK — no extra files to dig through. The QR in
the PR comment points at this universal-only artifact.
* chore(ci): build only universal APK for PR, drop split artifact
PR builds skip the arm64-only split — release-apk-signed now contains
just the universal app-release.apk, so the download zip is a single
file. Removes the redundant separate universal artifact and points
the PR comment QR at the main artifact URL.
* feat(mobile): suffix PR APK applicationId so it installs alongside production
Each PR build now becomes app.alextran.immich.pr<num> via PR_NUMBER env
read in build.gradle, so testers can install multiple PR builds and the
Play Store version on the same device without uninstalling. Also tags
the version with -pr<num> for visibility.
* feat(ci): allow PR APK build to run on forks
Forks can now run the Android build job. Steps that need repo secrets
(create-workflow-token, Create Keystore) are skipped when the PR is
from a fork, the checkout falls back to GITHUB_TOKEN, and build.gradle
falls back to debug signing if the release keystore isn't materialised.
The PR comment still requires write access, so it's gated to non-fork
PRs — fork APKs are reachable from the workflow run's artifact tab.
fix(mobile): deduplicate assets in person view timeline
Previously, assets with multiple face records for the same person (e.g.,
manual Digikam imports and Immich ML detections) appeared multiple times
in the person timeline. This was caused by an inner join on the
assetFaceEntity without proper deduplication.
This commit refactors the timeline queries to use a subquery approach
instead of joins and grouping. This ensures:
- _getPersonBucketAssets: Only unique assets are fetched, even if
multiple face records exist for a single asset.
- _watchPersonBucket: Asset counts in timeline headers are accurate
and represent unique assets.
- Performance: Database overhead is reduced by avoiding complex joins
and explicit groupBy operations on large result sets.
Signed-off-by: thowdev <12428285+thowdev@users.noreply.github.com>
_manualSyncAlbums fires a setState 1s after sync via Future.delayed
with no mounted check. if the widget is gone by then, setState throws
null check and the global error logger logs it severe.