Compare commits

..

33 Commits

Author SHA1 Message Date
renovate[bot]
5639c5af83 chore(deps): update dependency terragrunt to v0.98.0 2026-01-25 13:03:37 +00:00
Mert
5414302350 fix(mobile): do not try to load video as image (#25495)
* only load original for images

* early return
2026-01-24 18:38:31 -05:00
Mert
1803692eab feat(mobile): native clients (#21459)
* platform clients

* uppercase http method

* fix hot reload

* custom user agent

* init before app launch

* set defaults

* move to bootstrap

* unrelated change

* disable disk cache by default

* optimized decoding

* remove incremental

* android impl

* memory optimization

* lock approach is slower on ios

* conditional cronet

* clarify parameter

* enable disk cache

* set user agent

* flutter-side decode

* optimized http

* fixed locking

* refactor

* potential race conditions

* embedded cronet

* refactor, fix capacity handling

* fast path for known content length

* ios optimizations

* re-enable cache

* formatting

* bump concurrency

* clear cache button

* fix eviction race condition

* add extra cancellation check

* tighten dispose

* better error handling

* fix disposal

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-24 19:34:29 +00:00
Brandon Wees
9219d559a0 fix(mobile): share edited asset (#25493)
* fix(mobile): share edited asset

* chore: code review changes
2026-01-24 19:09:47 +00:00
Alex
d6c5a382f8 chore: show loading state when fetching users (#25277)
* chore: show loading state when fetching users

* pr feedback
2026-01-24 17:05:58 +00:00
Alex
deb3a620e1 feat: keep settings for free up space (#25460)
* feat: album exclusion filter in free up space

* feat: make keep options into persistent settings

* chore: refactor

* chore: refactor

* add free up space to app bar dialog

* fix: date selection rerender

* more copywriting

* Update i18n/en.json

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>

* add file size information

* styling

* clear up stale album id

* keep messaging album on first use

* feedback

* feedback

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2026-01-24 10:40:34 -06:00
Min Idzelis
7e5592fec5 feat: make progressive system config optional (#25486) 2026-01-24 00:18:02 -05:00
Brandon Wees
ccc0961ba3 fix: return original thumbs when edited=false (#25485) 2026-01-23 23:12:18 -06:00
Avalanche Ridings
497003ec57 feat: loop slideshows (#25462)
* Add Repeat to the slideshow in the web UI.

* Fix typo in SlideshowSettingsModal description prop

Fixed spelling

---------

Co-authored-by: generalzero <generalzero@generalzero.org>
2026-01-24 04:09:29 +00:00
Min Idzelis
d0d269677e refactor: rename mobileDevice to mediaQueryManager (#25464) 2026-01-23 20:55:10 -06:00
Brandon Wees
c2775894e1 fix(mobile): backfill asset dimensions to exif table (#25483) 2026-01-24 01:23:46 +00:00
Min Idzelis
357ec1394a feat: generate progressive JPEGs for thumbnails (#25463) 2026-01-24 00:35:07 +00:00
Jason Rasmussen
4fedae4150 refactor: event manager (#25481)
* refactor: event manager

* fix: broken downloadFile endpoint
2026-01-23 18:02:23 -05:00
Jason Rasmussen
b52e8cd570 refactor: asset navbar (#25480) 2026-01-23 16:19:46 -05:00
Daniel Dietzler
984fb12ada refactor: asset media service queries (#25477) 2026-01-23 16:07:57 -05:00
Daniel Dietzler
f88f1265b6 chore: update svelte (#25478) 2026-01-23 14:31:56 -06:00
Jason Rasmussen
af51a11b1b refactor: asset navbar (#25476) 2026-01-23 14:06:19 -05:00
Anukul
d942e7212a fix(web): fix badge value in queues page (#25445)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-01-23 16:09:19 +00:00
Daniel Dietzler
2792d97027 feat: more factories for small tests (#25396) 2026-01-23 10:54:04 -05:00
Yaros
574d9c34ff feat(mobile): star rating (#24457)
* feat(mobile): star rating

* refactor: use custom rating bar & provider

* refactor: remove user prop from provider

* feat: clear, padding, star size, impl suggestions

* chore: switch to rounded star icons

* fix: alignment & gesturedetector

* feat: rating search filter
2026-01-23 21:17:46 +05:30
renovate[bot]
3cb284c15a chore(deps): update dependency lodash to v4.17.23 [security] (#25441)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-23 16:18:19 +01:00
Mees Frensel
41c5a0ca2f fix(docs): document that fullsize thumbnail might redirect to original (#25416) 2026-01-23 09:42:21 -05:00
Jason Rasmussen
6d9dc46619 chore: include sync dtos (#25470) 2026-01-23 14:24:25 +00:00
Mees Frensel
20dca39143 fix(server): scoped permissions for more endpoints (#25452) 2026-01-23 09:03:57 -05:00
Min Idzelis
84679fb2b2 refactor: use assetCacheManager for OCR data (#25437) 2026-01-22 21:12:56 -06:00
Min Idzelis
a96a08939e refactor: rename preloadManager to imageManager (#25436)
rename: preloadManager to imageManager
2026-01-22 21:11:57 -06:00
shenlong
9b2939d778 fix(mobile): bring back map settings (#25448)
* fix(mobile): bring back map settings

* chore: styling

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-22 21:51:48 +00:00
shenlong
bccad2940e fix: incorrect asset viewer scale on image frame update (#25430)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-22 15:46:30 -06:00
Min Idzelis
dd72c32c60 feat: rename parallel tests to ui, split test step into: [e2e, ui] (#25439) 2026-01-22 13:44:00 -05:00
Brandon Wees
4bd01b70ff fix: asset edit sequence (#25457) 2026-01-22 12:41:01 -06:00
renovate[bot]
945f7fb9ea chore(deps): update dependency lodash-es to v4.17.23 [security] (#25453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 13:38:13 -05:00
Mees Frensel
78f400305b fix(web): don't show ocr button on panoramas (#25450) 2026-01-22 10:07:05 -06:00
bo0tzz
55477a8a1a chore: revert mise-action bump (#25451) 2026-01-22 16:53:14 +01:00
214 changed files with 5191 additions and 1802 deletions

View File

@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Load parameters
id: parameters

View File

@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Destroy Docs Subdomain
env:

View File

@@ -502,14 +502,25 @@ jobs:
- name: Run e2e tests (web)
env:
CI: true
run: npx playwright test
run: npx playwright test --project=chromium
if: ${{ !cancelled() }}
- name: Archive test results
- name: Archive web results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run ui tests (web)
env:
CI: true
run: npx playwright test --project=ui
if: ${{ !cancelled() }}
- name: Archive ui results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-ui-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]

View File

@@ -1,5 +1,5 @@
[tools]
terragrunt = "0.93.10"
terragrunt = "0.98.0"
opentofu = "1.10.7"
[tasks."tg:fmt"]

View File

@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
// {
// name: 'parallel tests',
// use: { ...devices['Desktop Chrome'] },
// testMatch: /.*\.parallel-e2e-spec\.ts/,
// fullyParallel: true,
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
// },
{
name: 'ui',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.ui-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
// name: 'firefox',

View File

@@ -104,6 +104,8 @@
"image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning",
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
"image_preview_title": "Preview Settings",
"image_progressive": "Progressive",
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
"image_quality": "Quality",
"image_resolution": "Resolution",
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
@@ -449,6 +451,9 @@
"admin_password": "Admin Password",
"administration": "Administration",
"advanced": "Advanced",
"advanced_settings_clear_image_cache": "Clear Image Cache",
"advanced_settings_clear_image_cache_error": "Failed to clear image cache",
"advanced_settings_clear_image_cache_success": "Successfully cleared {size}",
"advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.",
"advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
"advanced_settings_log_level_title": "Log level: {level}",
@@ -512,6 +517,7 @@
"all": "All",
"all_albums": "All albums",
"all_people": "All people",
"all_photos": "All photos",
"all_videos": "All videos",
"allow_dark_mode": "Allow dark mode",
"allow_edits": "Allow edits",
@@ -519,6 +525,9 @@
"allow_public_user_to_upload": "Allow public user to upload",
"allowed": "Allowed",
"alt_text_qr_code": "QR code image",
"always_keep": "Always keep",
"always_keep_photos_hint": "Free Up Space will keep all photos on this device.",
"always_keep_videos_hint": "Free Up Space will keep all videos on this device.",
"anti_clockwise": "Anti-clockwise",
"api_key": "API Key",
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
@@ -751,13 +760,13 @@
"cleanup_confirm_prompt_title": "Remove from this device?",
"cleanup_deleted_assets": "Moved {count} assets to device trash",
"cleanup_deleting": "Moving to trash...",
"cleanup_filter_description": "Choose which types of assets to remove in the cleanup",
"cleanup_found_assets": "Found {count} backed up assets",
"cleanup_found_assets_with_size": "Found {count} backed up assets ({size})",
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
"cleanup_no_assets_found": "No backed up assets found matching your criteria",
"cleanup_no_assets_found": "No assets found matching the criteria above. Free Up Space can only remove assets that have been backed up to the server",
"cleanup_preview_title": "Assets to remove ({count})",
"cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options",
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device",
"cleanup_step3_description": "Scan for backed up assets matching your date and keep settings.",
"cleanup_step4_summary": "{count} assets (created before {date}) to remove from your local device. Photos will remain accessible from the Immich app.",
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
"clear": "Clear",
"clear_all": "Clear all",
@@ -855,7 +864,7 @@
"custom_locale": "Custom Locale",
"custom_locale_description": "Format dates and numbers based on the language and the region",
"custom_url": "Custom URL",
"cutoff_date_description": "Remove photos and videos older than",
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
"daily_title_text_date": "E, MMM dd",
@@ -1007,6 +1016,7 @@
"error_change_sort_album": "Failed to change album sort order",
"error_delete_face": "Error deleting face from asset",
"error_getting_places": "Error getting places",
"error_loading_albums": "Error loading albums",
"error_loading_image": "Error loading image",
"error_loading_partners": "Error loading partners: {error}",
"error_retrieving_asset_information": "Error retrieving asset information",
@@ -1189,7 +1199,6 @@
"filetype": "Filetype",
"filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_options": "Filter options",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filters": "Filters",
@@ -1203,7 +1212,7 @@
"forgot_pin_code_question": "Forgot your PIN?",
"forward": "Forward",
"free_up_space": "Free Up Space",
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe",
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe.",
"free_up_space_settings_subtitle": "Free up device storage",
"full_path": "Full path: {path}",
"gcast_enabled": "Google Cast",
@@ -1320,10 +1329,15 @@
"json_editor": "JSON editor",
"json_error": "JSON error",
"keep": "Keep",
"keep_albums": "Keep albums",
"keep_albums_count": "Keeping {count} {count, plural, one {album} other {albums}}",
"keep_all": "Keep All",
"keep_description": "Choose what stays on your device when freeing up space.",
"keep_favorites": "Keep favorites",
"keep_favorites_description": "Favorite assets will not be deleted from your device",
"keep_on_device": "Keep on device",
"keep_on_device_hint": "Select items to keep on this device",
"keep_this_delete_others": "Keep this, delete others",
"keeping": "Keeping: {items}",
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
"keyboard_shortcuts": "Keyboard shortcuts",
"language": "Language",
@@ -1553,6 +1567,7 @@
"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.",
"no_albums_yet": "It looks like you do not have any albums yet.",
@@ -1582,6 +1597,7 @@
"no_results_description": "Try a synonym or more general keyword",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"no_uploads_in_progress": "No uploads in progress",
"none": "None",
"not_allowed": "Not allowed",
"not_available": "N/A",
"not_in_any_album": "Not in any album",
@@ -1911,6 +1927,7 @@
"search_filter_media_type_title": "Select media type",
"search_filter_ocr": "Search by OCR",
"search_filter_people_title": "Select people",
"search_filter_star_rating": "Star Rating",
"search_for": "Search for",
"search_for_existing_person": "Search for existing person",
"search_no_more_result": "No more results",
@@ -2115,6 +2132,8 @@
"skip_to_folders": "Skip to folders",
"skip_to_tags": "Skip to tags",
"slideshow": "Slideshow",
"slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings",
"sort_albums_by": "Sort albums by...",
"sort_created": "Date created",

View File

@@ -4,7 +4,7 @@ experimental_monorepo_root = true
node = "24.13.0"
flutter = "3.35.7"
pnpm = "10.28.0"
terragrunt = "0.93.10"
terragrunt = "0.98.0"
opentofu = "1.10.7"
java = "25.0.1"

View File

@@ -8,3 +8,5 @@ project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
)
target_link_libraries(native_buffer jnigraphics)

View File

@@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) {
android {
compileSdkVersion 35
ndkVersion = "28.1.13356709"
ndkVersion = "28.2.13676358"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -48,6 +48,7 @@ android {
}
buildFeatures {
buildConfig true
compose true
}
@@ -105,8 +106,11 @@ dependencies {
def serialization_version = '1.8.1'
def compose_version = '1.1.1'
def gson_version = '2.10.1'
def okhttp_version = '4.12.0'
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 "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

@@ -1,40 +1,38 @@
#include <jni.h>
#include <stdlib.h>
#include <string.h>
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_allocateNative(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_allocateNative(
Java_app_alextran_immich_NativeBuffer_allocate(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative(
Java_app_alextran_immich_NativeBuffer_free(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
}
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_realloc(
JNIEnv *env, jclass clazz, jlong address, jint size) {
void *ptr = realloc((void *) address, size);
return (jlong) ptr;
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_NativeBuffer_wrap(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_freeNative(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
Java_app_alextran_immich_NativeBuffer_copy(
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
void *src = (*env)->GetDirectBufferAddress(env, buffer);
if (src != NULL) {
memcpy((void *) destAddress, (char *) src + offset, length);
}
}

View File

@@ -2,6 +2,7 @@ package app.alextran.immich
import android.annotation.SuppressLint
import android.content.Context
import app.alextran.immich.core.SSLConfig
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
@@ -51,15 +52,18 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
when (call.method) {
"apply" -> {
val args = call.arguments<ArrayList<*>>()!!
val allowSelfSigned = args[0] as Boolean
val serverHost = args[1] as? String
val clientCertHash = (args[2] as? ByteArray)
var tm: Array<TrustManager>? = null
if (args[0] as Boolean) {
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
if (allowSelfSigned) {
tm = arrayOf(AllowSelfSignedTrustManager(serverHost))
}
var km: Array<KeyManager>? = null
if (args[2] != null) {
val cert = ByteArrayInputStream(args[2] as ByteArray)
if (clientCertHash != null) {
val cert = ByteArrayInputStream(clientCertHash)
val password = (args[3] as String).toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(cert, password)
@@ -69,6 +73,9 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
km = keyManagerFactory.keyManagers
}
// Update shared SSL config for OkHttp and other HTTP clients
SSLConfig.apply(km, tm, allowSelfSigned, serverHost, clientCertHash?.contentHashCode() ?: 0)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(km, tm, null)
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)

View File

@@ -10,8 +10,10 @@ import app.alextran.immich.background.BackgroundWorkerLockApi
import app.alextran.immich.connectivity.ConnectivityApi
import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.images.ThumbnailApi
import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -36,7 +38,9 @@ class MainActivity : FlutterFragmentActivity() {
NativeSyncApiImpl30(ctx)
}
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))

View File

@@ -0,0 +1,52 @@
package app.alextran.immich
import java.nio.ByteBuffer
const val INITIAL_BUFFER_SIZE = 32 * 1024
object NativeBuffer {
init {
System.loadLibrary("native_buffer")
}
@JvmStatic
external fun allocate(size: Int): Long
@JvmStatic
external fun free(address: Long)
@JvmStatic
external fun realloc(address: Long, size: Int): Long
@JvmStatic
external fun wrap(address: Long, capacity: Int): ByteBuffer
@JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
}
class NativeByteBuffer(initialCapacity: Int) {
var pointer = NativeBuffer.allocate(initialCapacity)
var capacity = initialCapacity
var offset = 0
inline fun ensureHeadroom() {
if (offset == capacity) {
capacity *= 2
pointer = NativeBuffer.realloc(pointer, capacity)
}
}
inline fun wrapRemaining() = NativeBuffer.wrap(pointer + offset, capacity - offset)
inline fun advance(bytesRead: Int) {
offset += bytesRead
}
inline fun free() {
if (pointer != 0L) {
NativeBuffer.free(pointer)
pointer = 0L
}
}
}

View File

@@ -0,0 +1,73 @@
package app.alextran.immich.core
import java.security.KeyStore
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* Shared SSL configuration for OkHttp and HttpsURLConnection.
* Stores the SSLSocketFactory and X509TrustManager configured by HttpSSLOptionsPlugin.
*/
object SSLConfig {
var sslSocketFactory: SSLSocketFactory? = null
private set
var trustManager: X509TrustManager? = null
private set
var requiresCustomSSL: Boolean = false
private set
private val listeners = mutableListOf<() -> Unit>()
private var configHash: Int = 0
fun addListener(listener: () -> Unit) {
listeners.add(listener)
}
fun apply(
keyManagers: Array<KeyManager>?,
trustManagers: Array<TrustManager>?,
allowSelfSigned: Boolean,
serverHost: String?,
clientCertHash: Int
) {
synchronized(this) {
val newHash = computeHash(allowSelfSigned, serverHost, clientCertHash)
val newRequiresCustomSSL = allowSelfSigned || keyManagers != null
if (newHash == configHash && sslSocketFactory != null && requiresCustomSSL == newRequiresCustomSSL) {
return // Config unchanged, skip
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(keyManagers, trustManagers, null)
sslSocketFactory = sslContext.socketFactory
trustManager = trustManagers?.filterIsInstance<X509TrustManager>()?.firstOrNull()
?: getDefaultTrustManager()
requiresCustomSSL = newRequiresCustomSSL
configHash = newHash
notifyListeners()
}
}
private fun computeHash(allowSelfSigned: Boolean, serverHost: String?, clientCertHash: Int): Int {
var result = allowSelfSigned.hashCode()
result = 31 * result + (serverHost?.hashCode() ?: 0)
result = 31 * result + clientCertHash
return result
}
private fun notifyListeners() {
listeners.forEach { it() }
}
private fun getDefaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
}
}

View File

@@ -13,7 +13,7 @@ import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object ThumbnailsPigeonUtils {
private object LocalImagesPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
@@ -47,7 +47,7 @@ class FlutterError (
override val message: String? = null,
val details: Any? = null
) : Throwable()
private open class ThumbnailsPigeonCodec : StandardMessageCodec() {
private open class LocalImagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
@@ -58,22 +58,22 @@ private open class ThumbnailsPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ThumbnailApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>>) -> Unit)
fun cancelImageRequest(requestId: Long)
interface LocalImageApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
companion object {
/** The codec used by ThumbnailApi. */
/** The codec used by LocalImageApi. */
val codec: MessageCodec<Any?> by lazy {
ThumbnailsPigeonCodec()
LocalImagesPigeonCodec()
}
/** Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. */
/** Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") {
fun setUp(binaryMessenger: BinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
@@ -82,13 +82,13 @@ interface ThumbnailApi {
val widthArg = args[2] as Long
val heightArg = args[3] as Long
val isVideoArg = args[4] as Boolean
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>> ->
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
reply.reply(LocalImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
reply.reply(LocalImagesPigeonUtils.wrapResult(data))
}
}
}
@@ -97,16 +97,16 @@ interface ThumbnailApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val requestIdArg = args[0] as Long
val wrapped: List<Any?> = try {
api.cancelImageRequest(requestIdArg)
api.cancelRequest(requestIdArg)
listOf(null)
} catch (exception: Throwable) {
ThumbnailsPigeonUtils.wrapError(exception)
LocalImagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -115,7 +115,7 @@ interface ThumbnailApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
@@ -123,10 +123,10 @@ interface ThumbnailApi {
api.getThumbhash(thumbhashArg) { result: Result<Map<String, Long>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
reply.reply(LocalImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
reply.reply(LocalImagesPigeonUtils.wrapResult(data))
}
}
}

View File

@@ -11,7 +11,8 @@ import android.os.OperationCanceledException
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
import java.nio.ByteBuffer
import androidx.annotation.RequiresApi
import app.alextran.immich.NativeBuffer
import kotlin.math.*
import java.util.concurrent.Executors
import com.bumptech.glide.Glide
@@ -26,10 +27,42 @@ import java.util.concurrent.Future
data class Request(
val taskFuture: Future<*>,
val cancellationSignal: CancellationSignal,
val callback: (Result<Map<String, Long>>) -> Unit
val callback: (Result<Map<String, Long>?>) -> Unit
)
class ThumbnailsImpl(context: Context) : ThumbnailApi {
@RequiresApi(Build.VERSION_CODES.Q)
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
}
fun Bitmap.toNativeBuffer(): Map<String, Long> {
val size = width * height * 4
val pointer = NativeBuffer.allocate(size)
try {
val buffer = NativeBuffer.wrap(pointer, size)
copyPixelsToBuffer(buffer)
recycle()
return mapOf(
"pointer" to pointer,
"width" to width.toLong(),
"height" to height.toLong(),
"rowBytes" to (width * 4).toLong()
)
} catch (e: Exception) {
NativeBuffer.free(pointer)
recycle()
throw e
}
}
class LocalImagesImpl(context: Context) : LocalImageApi {
private val ctx: Context = context.applicationContext
private val resolver: ContentResolver = ctx.contentResolver
private val requestThread = Executors.newSingleThreadExecutor()
@@ -38,21 +71,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
private val requestMap = ConcurrentHashMap<Long, Request>()
companion object {
val CANCELLED = Result.success<Map<String, Long>>(mapOf())
val CANCELLED = Result.success<Map<String, Long>?>(null)
val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
init {
System.loadLibrary("native_buffer")
}
@JvmStatic
external fun allocateNative(size: Int): Long
@JvmStatic
external fun freeNative(pointer: Long)
@JvmStatic
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
}
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
@@ -63,7 +83,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
val res = mapOf(
"pointer" to image.pointer,
"width" to image.width.toLong(),
"height" to image.height.toLong()
"height" to image.height.toLong(),
"rowBytes" to (image.width * 4).toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
@@ -78,7 +99,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
val task = threadPool.submit {
@@ -98,7 +119,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
requestMap[requestId] = request
}
override fun cancelImageRequest(requestId: Long) {
override fun cancelRequest(requestId: Long) {
val request = requestMap.remove(requestId) ?: return
request.taskFuture.cancel(false)
request.cancellationSignal.cancel()
@@ -117,7 +138,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit,
callback: (Result<Map<String, Long>?>) -> Unit,
signal: CancellationSignal
) {
signal.throwIfCanceled()
@@ -131,31 +152,12 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
decodeImage(id, size, signal)
}
processBitmap(bitmap, callback, signal)
}
private fun processBitmap(
bitmap: Bitmap, callback: (Result<Map<String, Long>>) -> Unit, signal: CancellationSignal
) {
signal.throwIfCanceled()
val actualWidth = bitmap.width
val actualHeight = bitmap.height
val size = actualWidth * actualHeight * 4
val pointer = allocateNative(size)
try {
signal.throwIfCanceled()
val buffer = wrapAsBuffer(pointer, size)
bitmap.copyPixelsToBuffer(buffer)
bitmap.recycle()
val res = bitmap.toNativeBuffer()
signal.throwIfCanceled()
val res = mapOf(
"pointer" to pointer, "width" to actualWidth.toLong(), "height" to actualHeight.toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
freeNative(pointer)
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
}
}
@@ -191,16 +193,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val source = ImageDecoder.createSource(resolver, uri)
signal.throwIfCanceled()
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
ImageDecoder.createSource(resolver, uri).decodeBitmap(target)
} else {
val ref =
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()

View File

@@ -0,0 +1,123 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.images
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object RemoteImagesPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}
private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface RemoteImageApi {
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit)
companion object {
/** The codec used by RemoteImageApi. */
val codec: MessageCodec<Any?> by lazy {
RemoteImagesPigeonCodec()
}
/** Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$separatedMessageChannelSuffix", codec)
if (api != null) {
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
api.requestImage(urlArg, headersArg, requestIdArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(RemoteImagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val requestIdArg = args[0] as Long
val wrapped: List<Any?> = try {
api.cancelRequest(requestIdArg)
listOf(null)
} catch (exception: Throwable) {
RemoteImagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.clearCache{ result: Result<Long> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(RemoteImagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -0,0 +1,530 @@
package app.alextran.immich.images
import android.content.Context
import android.os.CancellationSignal
import android.os.OperationCanceledException
import app.alextran.immich.BuildConfig
import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.SSLConfig
import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
import okhttp3.Callback
import okhttp3.ConnectionPool
import okhttp3.Dispatcher
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
import java.io.EOFException
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.file.FileVisitResult
import java.nio.file.Files
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
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
private const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
private const val MAX_REQUESTS_PER_HOST = 64
private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
class RemoteImagesImpl(context: Context) : RemoteImageApi {
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
init {
ImageFetcherManager.initialize(context)
}
companion object {
val CANCELLED = Result.success<Map<String, Long>?>(null)
}
override fun requestImage(
url: String,
headers: Map<String, String>,
requestId: Long,
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
requestMap[requestId] = RemoteRequest(signal)
ImageFetcherManager.fetch(
url,
headers,
signal,
onSuccess = { buffer ->
requestMap.remove(requestId)
if (signal.isCanceled) {
NativeBuffer.free(buffer.pointer)
return@fetch callback(CANCELLED)
}
callback(
Result.success(
mapOf(
"pointer" to buffer.pointer,
"length" to buffer.offset.toLong()
)
)
)
},
onFailure = { e ->
requestMap.remove(requestId)
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
callback(result)
}
)
}
override fun cancelRequest(requestId: Long) {
requestMap.remove(requestId)?.cancellationSignal?.cancel()
}
override fun clearCache(callback: (Result<Long>) -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
try {
ImageFetcherManager.clearCache(callback)
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
}
private object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
private var initialized = false
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
cacheDir = context.cacheDir
fetcher = build()
SSLConfig.addListener(::invalidate)
initialized = true
}
}
fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
fetcher.fetch(url, headers, signal, onSuccess, onFailure)
}
fun clearCache(onCleared: (Result<Long>) -> Unit) {
fetcher.clearCache(onCleared)
}
private fun invalidate() {
synchronized(this) {
val oldFetcher = fetcher
if (oldFetcher is OkHttpImageFetcher && SSLConfig.requiresCustomSSL) {
fetcher = oldFetcher.reconfigure(SSLConfig.sslSocketFactory, SSLConfig.trustManager)
return
}
fetcher = build()
oldFetcher.drain()
}
}
private fun build(): ImageFetcher {
return if (SSLConfig.requiresCustomSSL) {
OkHttpImageFetcher.create(cacheDir, SSLConfig.sslSocketFactory, SSLConfig.trustManager)
} else {
CronetImageFetcher(appContext, cacheDir)
}
}
}
private sealed interface ImageFetcher {
fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
)
fun drain()
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 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,
) {
synchronized(stateLock) {
if (draining) {
onFailure(IllegalStateException("Engine is draining"))
return
}
activeCount++
}
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
headers.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--
draining && activeCount == 0
}
if (didDrain) {
onDrained()
}
}
override fun drain() {
val didDrain = synchronized(stateLock) {
if (draining) return
draining = true
activeCount == 0
}
if (didDrain) {
onDrained()
}
}
private fun onDrained() {
engine.shutdown()
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
if (onCacheCleared == null) {
executor.shutdown()
} else {
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)
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
}
}
override fun clearCache(onCleared: (Result<Long>) -> Unit) {
synchronized(stateLock) {
if (onCacheCleared != null) {
return onCleared(Result.success(-1))
}
onCacheCleared = onCleared
}
drain()
}
private class FetchCallback(
private val onSuccess: (NativeByteBuffer) -> Unit,
private val onFailure: (Exception) -> Unit,
private val onComplete: () -> Unit,
) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null
private var wrapped: ByteBuffer? = null
private var error: Exception? = null
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
request.followRedirect()
}
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
if (info.httpStatusCode !in 200..299) {
error = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
return request.cancel()
}
try {
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
if (contentLength > 0) {
buffer = NativeByteBuffer(contentLength + 1)
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
request.read(wrapped)
} else {
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
request.read(buffer!!.wrapRemaining())
}
} catch (e: Exception) {
error = e
return request.cancel()
}
}
override fun onReadCompleted(
request: UrlRequest,
info: UrlResponseInfo,
byteBuffer: ByteBuffer
) {
try {
val buf = if (wrapped == null) {
buffer!!.run {
advance(byteBuffer.position())
ensureHeadroom()
wrapRemaining()
}
} else {
wrapped
}
request.read(buf)
} catch (e: Exception) {
error = e
return request.cancel()
}
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
wrapped?.let { buffer!!.advance(it.position()) }
onSuccess(buffer!!)
onComplete()
}
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
buffer?.free()
onFailure(error)
onComplete()
}
override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) {
buffer?.free()
onFailure(error ?: OperationCanceledException())
onComplete()
}
}
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
}
private class OkHttpImageFetcher private constructor(
private val client: OkHttpClient,
) : ImageFetcher {
private val stateLock = Any()
private var activeCount = 0
private var draining = false
companion object {
fun create(
cacheDir: File,
sslSocketFactory: SSLSocketFactory?,
trustManager: X509TrustManager?,
): OkHttpImageFetcher {
val dir = File(cacheDir, "okhttp")
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES,
timeUnit = TimeUnit.MINUTES
)
val builder = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.header("User-Agent", USER_AGENT)
.build()
)
}
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
.connectionPool(connectionPool)
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
if (sslSocketFactory != null && trustManager != null) {
builder.sslSocketFactory(sslSocketFactory, trustManager)
}
return OkHttpImageFetcher(builder.build())
}
}
fun reconfigure(
sslSocketFactory: SSLSocketFactory?,
trustManager: X509TrustManager?,
): OkHttpImageFetcher {
val builder = client.newBuilder()
if (sslSocketFactory != null && trustManager != null) {
builder.sslSocketFactory(sslSocketFactory, trustManager)
}
// Evict idle connections using old SSL config
client.connectionPool.evictAll()
return OkHttpImageFetcher(builder.build())
}
private fun onComplete() {
val shouldClose = synchronized(stateLock) {
activeCount--
draining && activeCount == 0
}
if (shouldClose) {
client.cache?.close()
}
}
override fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
synchronized(stateLock) {
if (draining) {
return onFailure(IllegalStateException("Client is draining"))
}
activeCount++
}
val requestBuilder = Request.Builder().url(url)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val call = client.newCall(requestBuilder.build())
signal.setOnCancelListener(call::cancel)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
onFailure(e)
onComplete()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
return onFailure(IOException("HTTP ${response.code}: ${response.message}")).also { onComplete() }
}
val body = response.body
?: return onFailure(IOException("Empty response body")).also { onComplete() }
if (call.isCanceled()) {
onFailure(OperationCanceledException())
return onComplete()
}
body.source().use { source ->
val length = body.contentLength().toInt()
val buffer = NativeByteBuffer(if (length > 0) length else INITIAL_BUFFER_SIZE)
try {
if (length > 0) {
val wrapped = NativeBuffer.wrap(buffer.pointer, length)
while (wrapped.hasRemaining()) {
if (call.isCanceled()) throw OperationCanceledException()
if (source.read(wrapped) == -1) throw EOFException()
}
buffer.advance(length)
} else {
while (true) {
if (call.isCanceled()) throw OperationCanceledException()
val bytesRead = source.read(buffer.wrapRemaining())
if (bytesRead == -1) break
buffer.advance(bytesRead)
buffer.ensureHeadroom()
}
}
onSuccess(buffer)
} catch (e: Exception) {
buffer.free()
onFailure(e)
}
onComplete()
}
}
}
})
}
override fun drain() {
val shouldClose = synchronized(stateLock) {
if (draining) return
draining = true
activeCount == 0
}
client.connectionPool.evictAll()
if (shouldClose) {
client.cache?.close()
}
}
override fun clearCache(onCleared: (Result<Long>) -> Unit) {
try {
val size = client.cache!!.size()
client.cache!!.evictAll()
onCleared(Result.success(size))
} catch (e: Exception) {
onCleared(Result.failure(e))
}
}
}

View File

@@ -7,6 +7,8 @@ package app.alextran.immich.images;
import java.nio.ByteBuffer;
import app.alextran.immich.NativeBuffer;
// modified to use native allocations
public final class ThumbHash {
/**
@@ -56,8 +58,8 @@ public final class ThumbHash {
int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
int size = w * h * 4;
long pointer = ThumbnailsImpl.allocateNative(size);
ByteBuffer rgba = ThumbnailsImpl.wrapAsBuffer(pointer, size);
long pointer = NativeBuffer.allocate(size);
ByteBuffer rgba = NativeBuffer.wrap(pointer, size);
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
float[] fx = new float[cx_stop];

View File

@@ -6,6 +6,9 @@ PODS:
- FlutterMacOS
- connectivity_plus (0.0.1):
- Flutter
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
@@ -77,6 +80,8 @@ PODS:
- Flutter
- network_info_plus (0.0.1):
- Flutter
- objective_c (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -136,6 +141,7 @@ DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
@@ -154,6 +160,7 @@ DEPENDENCIES:
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
@@ -184,6 +191,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
@@ -220,6 +229,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/native_video_player/ios"
network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -249,6 +260,7 @@ SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
@@ -270,6 +282,7 @@ SPEC CHECKSUMS:
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d

View File

@@ -29,9 +29,11 @@
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F12F1197D8006016CB /* LocalImages.g.swift */; };
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F22F1197D8006016CB /* RemoteImages.g.swift */; };
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; };
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; };
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
@@ -118,9 +120,11 @@
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
FE5499F12F1197D8006016CB /* LocalImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImages.g.swift; sourceTree = "<group>"; };
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImages.g.swift; sourceTree = "<group>"; };
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = "<group>"; };
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -321,9 +325,11 @@
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */,
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
);
path = Images;
sourceTree = "<group>";
@@ -600,12 +606,14 @@
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,

View File

@@ -53,7 +53,8 @@ import UIKit
public static func registerPlugins(with engine: FlutterEngine) {
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
}

View File

@@ -47,41 +47,41 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
}
private class ThumbnailsPigeonCodecReader: FlutterStandardReader {
private class LocalImagesPigeonCodecReader: FlutterStandardReader {
}
private class ThumbnailsPigeonCodecWriter: FlutterStandardWriter {
private class LocalImagesPigeonCodecWriter: FlutterStandardWriter {
}
private class ThumbnailsPigeonCodecReaderWriter: FlutterStandardReaderWriter {
private class LocalImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return ThumbnailsPigeonCodecReader(data: data)
return LocalImagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return ThumbnailsPigeonCodecWriter(data: data)
return LocalImagesPigeonCodecWriter(data: data)
}
}
class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = ThumbnailsPigeonCodec(readerWriter: ThumbnailsPigeonCodecReaderWriter())
class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = LocalImagesPigeonCodec(readerWriter: LocalImagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol ThumbnailApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], Error>) -> Void)
func cancelImageRequest(requestId: Int64) throws
protocol LocalImageApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class ThumbnailApiSetup {
static var codec: FlutterStandardMessageCodec { ThumbnailsPigeonCodec.shared }
/// Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") {
class LocalImageApiSetup {
static var codec: FlutterStandardMessageCodec { LocalImagesPigeonCodec.shared }
/// Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
@@ -102,22 +102,22 @@ class ThumbnailApiSetup {
} else {
requestImageChannel.setMessageHandler(nil)
}
let cancelImageRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelImageRequestChannel.setMessageHandler { message, reply in
cancelRequestChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let requestIdArg = args[0] as! Int64
do {
try api.cancelImageRequest(requestId: requestIdArg)
try api.cancelRequest(requestId: requestIdArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelImageRequestChannel.setMessageHandler(nil)
cancelRequestChannel.setMessageHandler(nil)
}
let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getThumbhashChannel.setMessageHandler { message, reply in
let args = message as! [Any?]

View File

@@ -1,19 +1,19 @@
import CryptoKit
import Accelerate
import Flutter
import MobileCoreServices
import Photos
class Request {
class LocalImageRequest {
weak var workItem: DispatchWorkItem?
var isCancelled = false
let callback: (Result<[String: Int64], any Error>) -> Void
let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64], any Error>) -> Void) {
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback
}
}
class ThumbnailApiImpl: ThumbnailApi {
class LocalImageApiImpl: LocalImageApi {
private static let imageManager = PHImageManager.default()
private static let fetchOptions = {
let fetchOptions = PHFetchOptions()
@@ -36,47 +36,39 @@ class ThumbnailApiImpl: ThumbnailApi {
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
private static var requests = [Int64: Request]()
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .defaultIntent
)!
private static var requests = [Int64: LocalImageRequest]()
private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
return assetCache
}()
private static let activitySemaphore = DispatchSemaphore(value: 1)
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.suspend()
activitySemaphore.wait()
}
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.resume()
activitySemaphore.signal()
}
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
"width": Int64(width),
"height": Int64(height),
"rowBytes": Int64(width * 4)
]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let request = Request(callback: completion)
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
let request = LocalImageRequest(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(Self.cancelledResult)
@@ -93,7 +85,7 @@ class ThumbnailApiImpl: ThumbnailApi {
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.removeRequest(requestId: requestId)
Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
@@ -119,70 +111,54 @@ class ThumbnailApiImpl: ThumbnailApi {
guard let image = image,
let cgImage = image.cgImage else {
Self.removeRequest(requestId: requestId)
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
alignment: MemoryLayout<UInt8>.alignment
)
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
guard let context = CGContext(
data: pointer,
width: cgImage.width,
height: cgImage.height,
bitsPerComponent: 8,
bytesPerRow: cgImage.width * 4,
space: Self.rgbColorSpace,
bitmapInfo: Self.bitmapInfo
) else {
pointer.deallocate()
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
return completion(Self.cancelledResult)
}
request.callback(.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
]))
print("Successful response for \(requestId)")
Self.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
}
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
context.interpolationQuality = .none
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId)
}
request.workItem = item
Self.addRequest(requestId: requestId, request: request)
Self.add(requestId: requestId, request: request)
Self.processingQueue.async(execute: item)
}
func cancelImageRequest(requestId: Int64) {
Self.cancelRequest(requestId: requestId)
func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId)
}
private static func addRequest(requestId: Int64, request: Request) -> Void {
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func removeRequest(requestId: Int64) -> Void {
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancelRequest(requestId: Int64) -> Void {
private static func cancel(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
@@ -203,9 +179,4 @@ class ThumbnailApiImpl: ThumbnailApi {
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset
}
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
}

View File

@@ -0,0 +1,134 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
return [
pigeonError.code,
pigeonError.message,
pigeonError.details,
]
}
if let flutterError = error as? FlutterError {
return [
flutterError.code,
flutterError.message,
flutterError.details,
]
}
return [
"\(error)",
"\(type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
private class RemoteImagesPigeonCodecReader: FlutterStandardReader {
}
private class RemoteImagesPigeonCodecWriter: FlutterStandardWriter {
}
private class RemoteImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return RemoteImagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return RemoteImagesPigeonCodecWriter(data: data)
}
}
class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = RemoteImagesPigeonCodec(readerWriter: RemoteImagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi {
func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class RemoteImageApiSetup {
static var codec: FlutterStandardMessageCodec { RemoteImagesPigeonCodec.shared }
/// Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
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
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestImageChannel.setMessageHandler(nil)
}
let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelRequestChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let requestIdArg = args[0] as! Int64
do {
try api.cancelRequest(requestId: requestIdArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelRequestChannel.setMessageHandler(nil)
}
let clearCacheChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
clearCacheChannel.setMessageHandler { _, reply in
api.clearCache { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
clearCacheChannel.setMessageHandler(nil)
}
}
}

View File

@@ -0,0 +1,184 @@
import Accelerate
import Flutter
import MobileCoreServices
import Photos
class RemoteImageRequest {
weak var task: URLSessionDataTask?
let id: Int64
var isCancelled = false
var data: CFMutableData?
let completion: (Result<[String: Int64]?, any Error>) -> Void
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.id = id
self.task = task
self.data = nil
self.completion = completion
}
}
class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static let delegate = RemoteImageApiDelegate()
static let session = {
let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true)
let config = URLSessionConfiguration.default
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
config.urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1 << 30,
directory: cacheDir
)
config.httpMaximumConnectionsPerHost = 64
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}()
func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!)
for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
let task = Self.session.dataTask(with: urlRequest)
let imageRequest = RemoteImageRequest(id: requestId, task: task, completion: completion)
Self.delegate.add(taskId: task.taskIdentifier, request: imageRequest)
task.resume()
}
func cancelRequest(requestId: Int64) {
Self.delegate.cancel(requestId: requestId)
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task {
let cache = Self.session.configuration.urlCache!
let cacheSize = Int64(cache.currentDiskUsage)
cache.removeAllCachedResponses()
completion(.success(cacheSize))
}
}
}
class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated, attributes: .concurrent)
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .perceptual
)!
private static var requestByTaskId = [Int: RemoteImageRequest]()
private static var taskIdByRequestId = [Int64: Int]()
private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
private static let decodeOptions = [
kCGImageSourceShouldCache: false,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
] as CFDictionary
func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let request = get(taskId: dataTask.taskIdentifier)
else {
return completionHandler(.cancel)
}
let capacity = max(Int(response.expectedContentLength), 0)
request.data = CFDataCreateMutable(nil, capacity)
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
didReceive data: Data) {
guard let request = get(taskId: dataTask.taskIdentifier) else { return }
data.withUnsafeBytes { bytes in
CFDataAppendBytes(request.data, bytes.bindMemory(to: UInt8.self).baseAddress, data.count)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask,
didCompleteWithError error: Error?) {
guard let request = get(taskId: task.taskIdentifier) else { return }
defer { remove(taskId: task.taskIdentifier, requestId: request.id) }
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
return request.completion(Self.cancelledResult)
}
return request.completion(.failure(error))
}
if request.isCancelled {
return request.completion(Self.cancelledResult)
}
guard let data = request.data else {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
guard let imageSource = CGImageSourceCreateWithData(data, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, Self.decodeOptions) else {
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
}
if request.isCancelled {
return request.completion(Self.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
return request.completion(Self.cancelledResult)
}
request.completion(
.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes),
]))
} catch {
return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil)))
}
}
@inline(__always) func get(taskId: Int) -> RemoteImageRequest? {
Self.requestQueue.sync { Self.requestByTaskId[taskId] }
}
@inline(__always) func add(taskId: Int, request: RemoteImageRequest) -> Void {
Self.requestQueue.async(flags: .barrier) {
Self.requestByTaskId[taskId] = request
Self.taskIdByRequestId[request.id] = taskId
}
}
@inline(__always) func remove(taskId: Int, requestId: Int64) -> Void {
Self.requestQueue.async(flags: .barrier) {
Self.taskIdByRequestId[requestId] = nil
Self.requestByTaskId[taskId] = nil
}
}
@inline(__always) func cancel(requestId: Int64) -> Void {
guard let request: RemoteImageRequest = (Self.requestQueue.sync {
guard let taskId = Self.taskIdByRequestId[requestId] else { return nil }
return Self.requestByTaskId[taskId]
}) else { return }
request.isCancelled = true
request.task?.cancel()
}
}

View File

@@ -8,6 +8,6 @@ enum SortUserBy { id }
enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, filterOptions, scan, delete }
enum CleanupStep { selectDate, scan, delete }
enum AssetFilterType { all, photosOnly, videosOnly }
enum AssetKeepType { none, photosOnly, videosOnly }

View File

@@ -30,3 +30,8 @@ class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}
// Map Events
class MapMarkerReloadEvent extends Event {
const MapMarkerReloadEvent();
}

View File

@@ -6,6 +6,7 @@ class ExifInfo {
final String? orientation;
final String? timeZone;
final DateTime? dateTimeOriginal;
final int? rating;
// GPS
final double? latitude;
@@ -46,6 +47,7 @@ class ExifInfo {
this.orientation,
this.timeZone,
this.dateTimeOriginal,
this.rating,
this.isFlipped = false,
this.latitude,
this.longitude,
@@ -71,6 +73,7 @@ class ExifInfo {
other.orientation == orientation &&
other.timeZone == timeZone &&
other.dateTimeOriginal == dateTimeOriginal &&
other.rating == rating &&
other.latitude == latitude &&
other.longitude == longitude &&
other.city == city &&
@@ -94,6 +97,7 @@ class ExifInfo {
isFlipped.hashCode ^
timeZone.hashCode ^
dateTimeOriginal.hashCode ^
rating.hashCode ^
latitude.hashCode ^
longitude.hashCode ^
city.hashCode ^
@@ -118,6 +122,7 @@ orientation: ${orientation ?? 'NA'},
isFlipped: $isFlipped,
timeZone: ${timeZone ?? 'NA'},
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
rating: ${rating ?? 'NA'},
latitude: ${latitude ?? 'NA'},
longitude: ${longitude ?? 'NA'},
city: ${city ?? 'NA'},
@@ -140,6 +145,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
String? orientation,
String? timeZone,
DateTime? dateTimeOriginal,
int? rating,
double? latitude,
double? longitude,
String? city,
@@ -161,6 +167,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
orientation: orientation ?? this.orientation,
timeZone: timeZone ?? this.timeZone,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
rating: rating ?? this.rating,
isFlipped: isFlipped ?? this.isFlipped,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,

View File

@@ -82,7 +82,14 @@ enum StoreKey<T> {
useWifiForUploadPhotos<bool>._(1005),
needBetaMigration<bool>._(1006),
// TODO: Remove this after patching open-api
shouldResetSync<bool>._(1007);
shouldResetSync<bool>._(1007),
// Free up space
cleanupKeepFavorites<bool>._(1008),
cleanupKeepMediaType<int>._(1009),
cleanupKeepAlbumIds<String>._(1010),
cleanupCutoffDaysAgo<int>._(1011),
cleanupDefaultsInitialized<bool>._(1012);
const StoreKey._(this.id);
final int id;

View File

@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/map.model.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
@@ -11,7 +12,8 @@ class MapFactory {
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId));
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
MapService(_mapRepository.remote(ownerIds, options));
}
class MapService {

View File

@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -82,8 +81,8 @@ class TimelineFactory {
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
TimelineService map(String userId, LatLngBounds bounds) =>
TimelineService(_timelineRepository.map(userId, bounds, groupBy));
TimelineService map(List<String> userIds, TimelineMapOptions options) =>
TimelineService(_timelineRepository.map(userIds, options, groupBy));
}
class TimelineService {

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart';
@@ -22,8 +23,13 @@ class BackgroundSyncManager {
final SyncCallback? onHashingComplete;
final SyncErrorCallback? onHashingError;
final SyncCallback? onCloudIdSyncStart;
final SyncCallback? onCloudIdSyncComplete;
final SyncErrorCallback? onCloudIdSyncError;
Cancelable<bool?>? _syncTask;
Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _cloudIdSyncTask;
Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _linkedAlbumSyncTask;
Cancelable<void>? _hashTask;
@@ -38,6 +44,9 @@ class BackgroundSyncManager {
this.onHashingStart,
this.onHashingComplete,
this.onHashingError,
this.onCloudIdSyncStart,
this.onCloudIdSyncComplete,
this.onCloudIdSyncError,
});
Future<void> cancel() async {
@@ -55,6 +64,12 @@ class BackgroundSyncManager {
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future);
}
@@ -201,6 +216,25 @@ class BackgroundSyncManager {
_linkedAlbumSyncTask = null;
});
}
Future<void> syncCloudIds() {
if (_cloudIdSyncTask != null) {
return _cloudIdSyncTask!.future;
}
onCloudIdSyncStart?.call();
_cloudIdSyncTask = runInIsolateGentle(computation: m.syncCloudIds);
return _cloudIdSyncTask!
.whenComplete(() {
onCloudIdSyncComplete?.call();
_cloudIdSyncTask = null;
})
.catchError((error) {
onCloudIdSyncError?.call(error.toString());
_cloudIdSyncTask = null;
});
}
}
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(

View File

@@ -10,13 +10,14 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart' hide AssetVisibility;
Future<void> syncCloudIds(Ref ref) async {
Future<void> syncCloudIds(ProviderContainer ref) async {
if (!CurrentPlatform.isIOS) {
return;
}
@@ -34,6 +35,14 @@ Future<void> syncCloudIds(Ref ref) async {
}
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
try {
await ref.read(syncStreamServiceProvider).sync();
} catch (e, s) {
logger.fine('Failed to complete remote sync before cloudId migration.', e, s);
return;
}
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {

View File

@@ -151,6 +151,7 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
domain.ExifInfo toDto() => domain.ExifInfo(
fileSize: fileSize,
dateTimeOriginal: dateTimeOriginal,
rating: rating,
timeZone: timeZone,
make: make,
model: model,

View File

@@ -1,15 +1,12 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ffi/ffi.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:logging/logging.dart';
part 'local_image_request.dart';
part 'thumbhash_image_request.dart';
@@ -37,27 +34,61 @@ abstract class ImageRequest {
void _onCancelled();
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
final address = info['pointer'];
if (address == null) {
return null;
}
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {
malloc.free(pointer);
return null;
}
final int actualWidth;
final int actualHeight;
final int actualSize;
final ui.ImmutableBuffer buffer;
try {
actualWidth = info['width']!;
actualHeight = info['height']!;
actualSize = actualWidth * actualHeight * 4;
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(length));
} finally {
malloc.free(pointer);
}
if (_isCancelled) {
buffer.dispose();
return null;
}
final descriptor = await ui.ImageDescriptor.encoded(buffer);
buffer.dispose();
if (_isCancelled) {
descriptor.dispose();
return null;
}
final codec = await descriptor.instantiateCodec();
if (_isCancelled) {
descriptor.dispose();
codec.dispose();
return null;
}
final frame = await codec.getNextFrame();
descriptor.dispose();
codec.dispose();
if (_isCancelled) {
frame.image.dispose();
return null;
}
return frame;
}
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {
malloc.free(pointer);
return null;
}
final size = rowBytes * height;
final ui.ImmutableBuffer buffer;
try {
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(size));
} finally {
malloc.free(pointer);
}
@@ -69,18 +100,28 @@ abstract class ImageRequest {
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: actualWidth,
height: actualHeight,
width: width,
height: height,
rowBytes: rowBytes,
pixelFormat: ui.PixelFormat.rgba8888,
);
buffer.dispose();
final codec = await descriptor.instantiateCodec();
if (_isCancelled) {
buffer.dispose();
descriptor.dispose();
codec.dispose();
return null;
}
return await codec.getNextFrame();
final frame = await codec.getNextFrame();
descriptor.dispose();
codec.dispose();
if (_isCancelled) {
frame.image.dispose();
return null;
}
return frame;
}
}

View File

@@ -16,20 +16,23 @@ class LocalImageRequest extends ImageRequest {
return null;
}
final Map<String, int> info = await thumbnailApi.requestImage(
final info = await localImageApi.requestImage(
localId,
requestId: requestId,
width: width,
height: height,
isVideo: assetType == AssetType.video,
);
if (info == null) {
return null;
}
final frame = await _fromPlatformImage(info);
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
Future<void> _onCancelled() {
return thumbnailApi.cancelImageRequest(requestId);
return localImageApi.cancelRequest(requestId);
}
}

View File

@@ -1,14 +1,10 @@
part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest {
static final log = Logger('RemoteImageRequest');
static final client = HttpClient()..maxConnectionsPerHost = 16;
final RemoteCacheManager? cacheManager;
final String uri;
final Map<String, String> headers;
HttpClientRequest? _request;
RemoteImageRequest({required this.uri, required this.headers, this.cacheManager});
RemoteImageRequest({required this.uri, required this.headers});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@@ -16,164 +12,18 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
// so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call.
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
if (cachedFileImage != null) {
return cachedFileImage;
}
try {
final buffer = await _downloadImage(uri);
if (buffer == null) {
return null;
}
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
if (_isCancelled) {
return null;
}
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
if (cachedFileImage != null) {
return cachedFileImage;
}
rethrow;
} finally {
_request = null;
}
}
Future<ImmutableBuffer?> _downloadImage(String url) async {
if (_isCancelled) {
return null;
}
final request = _request = await client.getUrl(Uri.parse(url));
if (_isCancelled) {
request.abort();
return _request = null;
}
for (final entry in headers.entries) {
request.headers.set(entry.key, entry.value);
}
final response = await request.close();
if (_isCancelled) {
return null;
}
final cacheManager = this.cacheManager;
final streamController = StreamController<List<int>>(sync: true);
final Stream<List<int>> stream;
unawaited(cacheManager?.putStreamedFile(url, streamController.stream));
stream = response.map((chunk) {
if (_isCancelled) {
throw StateError('Cancelled request');
}
if (cacheManager != null) {
streamController.add(chunk);
}
return chunk;
});
try {
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
unawaited(streamController.close());
return await ImmutableBuffer.fromUint8List(bytes);
} catch (e) {
streamController.addError(e);
unawaited(streamController.close());
if (_isCancelled) {
return null;
}
rethrow;
}
}
Future<Uint8List> _downloadBytes(Stream<List<int>> stream, int length) async {
final Uint8List bytes;
int offset = 0;
if (length > 0) {
// Known content length - use pre-allocated buffer
bytes = Uint8List(length);
await stream.listen((chunk) {
bytes.setAll(offset, chunk);
offset += chunk.length;
}, cancelOnError: true).asFuture();
} else {
// Unknown content length - collect chunks dynamically
final chunks = <List<int>>[];
int totalLength = 0;
await stream.listen((chunk) {
chunks.add(chunk);
totalLength += chunk.length;
}, cancelOnError: true).asFuture();
bytes = Uint8List(totalLength);
for (final chunk in chunks) {
bytes.setAll(offset, chunk);
offset += chunk.length;
}
}
return bytes;
}
Future<ImageInfo?> _loadCachedFile(
String url,
ImageDecoderCallback decode,
double scale, {
required bool inMemoryOnly,
}) async {
final cacheManager = this.cacheManager;
if (_isCancelled || cacheManager == null) {
return null;
}
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
if (_isCancelled || file == null) {
return null;
}
try {
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
log.severe('Failed to decode cached image', e);
unawaited(_evictFile(url));
return null;
}
}
Future<void> _evictFile(String url) async {
try {
await cacheManager?.removeFile(url);
} catch (e) {
log.severe('Failed to remove cached image', e);
}
}
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
if (_isCancelled) {
buffer.dispose();
return null;
}
final codec = await decode(buffer);
if (_isCancelled) {
buffer.dispose();
codec.dispose();
return null;
}
final frame = await codec.getNextFrame();
return ImageInfo(image: frame.image, scale: scale);
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
_ => null,
};
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
void _onCancelled() {
_request?.abort();
_request = null;
Future<void> _onCancelled() {
return remoteImageApi.cancelRequest(requestId);
}
}

View File

@@ -11,8 +11,8 @@ class ThumbhashImageRequest extends ImageRequest {
return null;
}
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
final frame = await _fromPlatformImage(info);
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash);
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}

View File

@@ -11,6 +11,13 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class RemovalCandidatesResult {
final List<LocalAsset> assets;
final int totalBytes;
const RemovalCandidatesResult({required this.assets, required this.totalBytes});
}
class DriftLocalAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -130,11 +137,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return result;
}
Future<List<LocalAsset>> getRemovalCandidates(
Future<RemovalCandidatesResult> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
AssetKeepType keepMediaType = AssetKeepType.none,
bool keepFavorites = true,
Set<String> keepAlbumIds = const {},
}) async {
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
@@ -149,6 +157,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
final query = _db.localAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),
leftOuterJoin(_db.remoteExifEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteExifEntity.assetId)),
]);
Expression<bool> whereClause =
@@ -159,10 +168,19 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
// Exclude assets that are in iOS shared albums
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets);
if (filterType == AssetFilterType.photosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
} else if (filterType == AssetFilterType.videosOnly) {
if (keepAlbumIds.isNotEmpty) {
final keepAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(_db.localAlbumAssetEntity.albumId.isIn(keepAlbumIds));
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(keepAlbumAssets);
}
if (keepMediaType == AssetKeepType.photosOnly) {
// Keep photos = delete only videos
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video);
} else if (keepMediaType == AssetKeepType.videosOnly) {
// Keep videos = delete only photos
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
}
if (keepFavorites) {
@@ -172,7 +190,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
query.where(whereClause);
final rows = await query.get();
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
final assets = rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
final totalBytes = rows.fold<int>(0, (sum, row) {
final fileSize = row.readTableOrNull(_db.remoteExifEntity)?.fileSize;
return sum + (fileSize ?? 0);
});
return RemovalCandidatesResult(assets: assets, totalBytes: totalBytes);
}
Future<List<LocalAsset>> getEmptyCloudIdAssets() {

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/map.service.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class DriftMapRepository extends DriftDatabaseRepository {
@@ -12,9 +13,27 @@ class DriftMapRepository extends DriftDatabaseRepository {
const DriftMapRepository(super._db) : _db = _db;
MapQuery remote(String ownerId) => _mapQueryBuilder(
assetFilter: (row) =>
row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId),
MapQuery remote(List<String> ownerIds, TimelineMapOptions options) => _mapQueryBuilder(
assetFilter: (row) {
Expression<bool> condition =
row.deletedAt.isNull() &
row.ownerId.isIn(ownerIds) &
_db.remoteAssetEntity.visibility.isIn([
AssetVisibility.timeline.index,
if (options.includeArchived) AssetVisibility.archive.index,
]);
if (options.onlyFavorites) {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
}
return condition;
},
);
MapQuery _mapQueryBuilder({Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter}) {

View File

@@ -0,0 +1,67 @@
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';
class NetworkRepository {
static late Directory _cachePath;
static late String _userAgent;
static final _clients = <String, http.Client>{};
static Future<void> init() {
return (
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
getUserAgentString().then((userAgent) => _userAgent = userAgent),
).wait;
}
static void reset() {
Future.microtask(init);
for (final client in _clients.values) {
client.close();
}
_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);
}
}

View File

@@ -255,6 +255,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
);
}
Future<void> updateRating(String assetId, int rating) async {
await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write(
RemoteExifEntityCompanion(rating: Value(rating)),
);
}
Future<int> getCount() {
return _db.managers.remoteAssetEntity.count();
}

View File

@@ -31,6 +31,7 @@ class SearchApiRepository extends ApiRepository {
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
@@ -54,6 +55,7 @@ class SearchApiRepository extends ApiRepository {
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),

View File

@@ -240,6 +240,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
rating: Value(exif.rating),
projectionType: Value(exif.projectionType),
lens: Value(exif.lensModel),
width: Value(exif.exifImageWidth),
height: Value(exif.exifImageHeight),
);
batch.insert(

View File

@@ -15,6 +15,22 @@ import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:stream_transform/stream_transform.dart';
class TimelineMapOptions {
final LatLngBounds bounds;
final bool onlyFavorites;
final bool includeArchived;
final bool withPartners;
final int relativeDays;
const TimelineMapOptions({
required this.bounds,
this.onlyFavorites = false,
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
});
}
class DriftTimelineRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -467,15 +483,15 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
TimelineQuery map(String userId, LatLngBounds bounds, GroupAssetsBy groupBy) => (
bucketSource: () => _watchMapBucket(userId, bounds, groupBy: groupBy),
assetSource: (offset, count) => _getMapBucketAssets(userId, bounds, offset: offset, count: count),
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
origin: TimelineOrigin.map,
);
Stream<List<Bucket>> _watchMapBucket(
String userId,
LatLngBounds bounds, {
List<String> userId,
TimelineMapOptions options, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
if (groupBy == GroupAssetsBy.none) {
@@ -496,14 +512,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
])
..where(
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteExifEntity.inBounds(bounds) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.ownerId.isIn(userId) &
_db.remoteExifEntity.inBounds(options.bounds) &
_db.remoteAssetEntity.visibility.isIn([
AssetVisibility.timeline.index,
if (options.includeArchived) AssetVisibility.archive.index,
]) &
_db.remoteAssetEntity.deletedAt.isNull(),
)
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
if (options.onlyFavorites) {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
return query.map((row) {
final timeline = row.read(dateExp)!.truncateDate(groupBy);
final assetCount = row.read(assetCountExp)!;
@@ -512,8 +540,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
Future<List<BaseAsset>> _getMapBucketAssets(
String userId,
LatLngBounds bounds, {
List<String> userId,
TimelineMapOptions options, {
required int offset,
required int count,
}) {
@@ -526,13 +554,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
])
..where(
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteExifEntity.inBounds(bounds) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.ownerId.isIn(userId) &
_db.remoteExifEntity.inBounds(options.bounds) &
_db.remoteAssetEntity.visibility.isIn([
AssetVisibility.timeline.index,
if (options.includeArchived) AssetVisibility.archive.index,
]) &
_db.remoteAssetEntity.deletedAt.isNull(),
)
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);
if (options.onlyFavorites) {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}

View File

@@ -19,6 +19,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/intl_keys.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
@@ -237,6 +238,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
super.dispose();
}
@override
void reassemble() {
if (kDebugMode) {
NetworkRepository.reset();
}
super.reassemble();
}
@override
Widget build(BuildContext context) {
final router = ref.watch(appRouterProvider);

View File

@@ -126,6 +126,41 @@ class SearchDateFilter {
int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode;
}
class SearchRatingFilter {
int? rating;
SearchRatingFilter({this.rating});
SearchRatingFilter copyWith({int? rating}) {
return SearchRatingFilter(rating: rating ?? this.rating);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{'rating': rating};
}
factory SearchRatingFilter.fromMap(Map<String, dynamic> map) {
return SearchRatingFilter(rating: map['rating'] != null ? map['rating'] as int : null);
}
String toJson() => json.encode(toMap());
factory SearchRatingFilter.fromJson(String source) =>
SearchRatingFilter.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'SearchRatingFilter(rating: $rating)';
@override
bool operator ==(covariant SearchRatingFilter other) {
if (identical(this, other)) return true;
return other.rating == rating;
}
@override
int get hashCode => rating.hashCode;
}
class SearchDisplayFilters {
bool isNotInAlbum = false;
bool isArchive = false;
@@ -183,6 +218,7 @@ class SearchFilter {
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
SearchRatingFilter rating;
SearchDisplayFilters display;
// Enum
@@ -200,6 +236,7 @@ class SearchFilter {
required this.camera,
required this.date,
required this.display,
required this.rating,
required this.mediaType,
});
@@ -220,6 +257,7 @@ class SearchFilter {
display.isNotInAlbum == false &&
display.isArchive == false &&
display.isFavorite == false &&
rating.rating == null &&
mediaType == AssetType.other;
}
@@ -235,6 +273,7 @@ class SearchFilter {
SearchCameraFilter? camera,
SearchDateFilter? date,
SearchDisplayFilters? display,
SearchRatingFilter? rating,
AssetType? mediaType,
}) {
return SearchFilter(
@@ -249,13 +288,14 @@ class SearchFilter {
camera: camera ?? this.camera,
date: date ?? this.date,
display: display ?? this.display,
rating: rating ?? this.rating,
mediaType: mediaType ?? this.mediaType,
);
}
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType, assetId: $assetId)';
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
}
@override
@@ -273,6 +313,7 @@ class SearchFilter {
other.camera == camera &&
other.date == date &&
other.display == display &&
other.rating == rating &&
other.mediaType == mediaType;
}
@@ -289,6 +330,7 @@ class SearchFilter {
camera.hashCode ^
date.hashCode ^
display.hashCode ^
rating.hashCode ^
mediaType.hashCode;
}
}

View File

@@ -142,7 +142,7 @@ class SettingsSubPage extends StatelessWidget {
context.locale;
return Scaffold(
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
body: Padding(padding: const EdgeInsets.only(bottom: 60.0), child: section.widget),
body: section.widget,
);
}
}

View File

@@ -75,6 +75,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();

View File

@@ -113,6 +113,7 @@ class PlaceTile extends StatelessWidget {
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
),
),

View File

@@ -43,6 +43,7 @@ class SearchPage extends HookConsumerWidget {
date: prefilter?.date ?? SearchDateFilter(),
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
mediaType: prefilter?.mediaType ?? AssetType.other,
rating: prefilter?.rating ?? SearchRatingFilter(),
language: "${context.locale.languageCode}-${context.locale.countryCode}",
),
);

137
mobile/lib/platform/local_image_api.g.dart generated Normal file
View File

@@ -0,0 +1,137 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class LocalImageApi {
/// Constructor for [LocalImageApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
LocalImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(
String assetId, {
required int requestId,
required int width,
required int height,
required bool isVideo,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
assetId,
requestId,
width,
height,
isVideo,
]);
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 (pigeonVar_replyList[0] as Map<Object?, Object?>?)?.cast<String, int>();
}
}
Future<void> cancelRequest(int requestId) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
Future<Map<String, int>> getThumbhash(String thumbhash) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[thumbhash]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, int>();
}
}
}

View File

@@ -0,0 +1,129 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class RemoteImageApi {
/// Constructor for [RemoteImageApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
RemoteImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(
String url, {
required Map<String, String> headers,
required int requestId,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]);
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 (pigeonVar_replyList[0] as Map<Object?, Object?>?)?.cast<String, int>();
}
}
Future<void> cancelRequest(int requestId) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
Future<int> clearCache() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$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?)!;
}
}
}

View File

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map_settings_sheet.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
@@ -10,6 +11,16 @@ class DriftMapPage extends StatelessWidget {
const DriftMapPage({super.key, this.initialLocation});
void onSettingsPressed(BuildContext context) {
showModalBottomSheet(
elevation: 0.0,
showDragHandle: true,
isScrollControlled: true,
context: context,
builder: (_) => const DriftMapSettingsSheet(),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -18,8 +29,8 @@ class DriftMapPage extends StatelessWidget {
children: [
DriftMap(initialLocation: initialLocation),
Positioned(
left: 16,
top: 60,
left: 20,
top: 70,
child: IconButton.filled(
color: Colors.white,
onPressed: () => context.pop(),
@@ -32,6 +43,21 @@ class DriftMapPage extends StatelessWidget {
),
),
),
Positioned(
right: 20,
top: 70,
child: IconButton.filled(
color: Colors.white,
onPressed: () => onSettingsPressed(context),
icon: const Icon(Icons.more_vert_rounded),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(8),
backgroundColor: Colors.indigo,
shadowColor: Colors.black26,
elevation: 4,
),
),
),
],
),
);

View File

@@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/feature_check.dart';
@@ -30,6 +31,7 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
import 'package:immich_mobile/widgets/search/search_filter/star_rating_picker.dart';
@RoutePage()
class DriftSearchPage extends HookConsumerWidget {
@@ -48,6 +50,7 @@ class DriftSearchPage extends HookConsumerWidget {
camera: preFilter?.camera ?? SearchCameraFilter(),
date: preFilter?.date ?? SearchDateFilter(),
display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: preFilter?.rating ?? SearchRatingFilter(),
mediaType: preFilter?.mediaType ?? AssetType.other,
language: "${context.locale.languageCode}-${context.locale.countryCode}",
assetId: preFilter?.assetId,
@@ -62,10 +65,15 @@ class DriftSearchPage extends HookConsumerWidget {
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(message, style: context.textTheme.labelLarge),
@@ -369,6 +377,35 @@ class DriftSearchPage extends HookConsumerWidget {
);
}
// STAR RATING PICKER
showStarRatingPicker() {
handleOnSelected(SearchRatingFilter rating) {
filter.value = filter.value.copyWith(rating: rating);
ratingCurrentFilterWidget.value = Text(
'rating_count'.t(args: {'count': rating.rating!}),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(rating: SearchRatingFilter(rating: null));
ratingCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FilterBottomSheetScaffold(
title: 'rating'.t(context: context),
onSearch: search,
onClear: handleClear,
child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating),
),
);
}
// DISPLAY OPTION
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
@@ -629,6 +666,14 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
if (isRatingEnabled) ...[
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value,
),
],
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,

View File

@@ -34,6 +34,7 @@ class SimilarPhotosActionButton extends ConsumerWidget {
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);

View File

@@ -118,7 +118,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bool dragInProgress = false;
bool shouldPopOnDrag = false;
bool assetReloadRequested = false;
double? initialScale;
double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero;
int totalAssets = 0;
@@ -264,7 +263,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing
initialScale = controller.scale;
controller.scale = (controller.scale ?? 1.0) + 0.01;
}
}
@@ -316,7 +314,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
hasDraggedDown = null;
viewController?.animateMultiple(
position: initialPhotoViewState.position,
scale: initialPhotoViewState.scale,
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
rotation: initialPhotoViewState.rotation,
);
ref.read(assetViewerProvider.notifier).setOpacity(255);
@@ -366,8 +364,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final maxScaleDistance = ctx.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
double? updatedScale;
if (initialPhotoViewState.scale != null) {
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
if (initialScale != null) {
updatedScale = initialScale * (1.0 - scaleReduction);
}
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
@@ -481,8 +480,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
initialScale = viewController?.scale;
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
@@ -504,7 +501,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _handleSheetClose() {
viewController?.animateMultiple(position: Offset.zero);
viewController?.updateMultiple(scale: initialScale);
viewController?.updateMultiple(scale: viewController?.initialScale);
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
sheetCloseController = null;
shouldPopOnDrag = false;

View File

@@ -16,11 +16,13 @@ import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -204,6 +206,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
// Build file info tile based on asset type
Widget buildFileInfoTile() {
@@ -283,6 +288,38 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Rating bar
if (isRatingEnabled) ...[
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'rating'.t(context: context).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,
filledColor: context.themeData.colorScheme.primary,
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
itemSize: 40,
onRatingUpdate: (rating) async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
},
onClearRating: () async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
},
),
],
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class RatingBar extends StatefulWidget {
final double initialRating;
final int itemCount;
final double itemSize;
final Color filledColor;
final Color unfilledColor;
final ValueChanged<int>? onRatingUpdate;
final VoidCallback? onClearRating;
final Widget? itemBuilder;
final double starPadding;
const RatingBar({
super.key,
this.initialRating = 0.0,
this.itemCount = 5,
this.itemSize = 40.0,
this.filledColor = Colors.amber,
this.unfilledColor = Colors.grey,
this.onRatingUpdate,
this.onClearRating,
this.itemBuilder,
this.starPadding = 4.0,
});
@override
State<RatingBar> createState() => _RatingBarState();
}
class _RatingBarState extends State<RatingBar> {
late double _currentRating;
@override
void initState() {
super.initState();
_currentRating = widget.initialRating;
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx;
if (isRTL) dx = totalWidth - dx;
double newRating;
if (dx <= 0) {
newRating = 0;
} else if (dx >= totalWidth) {
newRating = widget.itemCount.toDouble();
} else {
double starWithPadding = widget.itemSize + widget.starPadding;
int tappedIndex = (dx / starWithPadding).floor().clamp(0, widget.itemCount - 1);
newRating = tappedIndex + 1.0;
if (isTap && newRating == _currentRating && _currentRating != 0) {
newRating = 0;
}
}
if (_currentRating != newRating) {
setState(() {
_currentRating = newRating;
});
widget.onRatingUpdate?.call(newRating.round());
}
}
@override
Widget build(BuildContext context) {
final isRTL = Directionality.of(context) == TextDirection.rtl;
final double visualAlignmentOffset = 5.0;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Transform.translate(
offset: Offset(isRTL ? visualAlignmentOffset : -visualAlignmentOffset, 0),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => _updateRating(details.localPosition, isRTL, isTap: true),
onPanUpdate: (details) => _updateRating(details.localPosition, isRTL, isTap: false),
child: Row(
mainAxisSize: MainAxisSize.min,
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
children: List.generate(widget.itemCount * 2 - 1, (i) {
if (i.isOdd) {
return SizedBox(width: widget.starPadding);
}
int index = i ~/ 2;
bool filled = _currentRating > index;
return widget.itemBuilder ??
Icon(
Icons.star_rounded,
size: widget.itemSize,
color: filled ? widget.filledColor : widget.unfilledColor,
);
}),
),
),
),
if (_currentRating > 0)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: GestureDetector(
onTap: () {
setState(() {
_currentRating = 0;
});
widget.onClearRating?.call();
},
child: Text(
'rating_clear'.t(context: context),
style: TextStyle(color: context.themeData.colorScheme.primary),
),
),
),
],
);
}
}

View File

@@ -14,7 +14,7 @@ class MapBottomSheet extends StatelessWidget {
Widget build(BuildContext context) {
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.9,
maxChildSize: 0.75,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
actions: [],
@@ -38,8 +38,13 @@ class _ScopedMapTimeline extends StatelessWidget {
throw Exception('User must be logged in to access archive');
}
final bounds = ref.watch(mapStateProvider).bounds;
final timelineService = ref.watch(timelineFactoryProvider).map(user.id, bounds);
final users = ref.watch(mapStateProvider).withPartners
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final timelineService = ref
.watch(timelineFactoryProvider)
.map(users, ref.watch(mapStateProvider).toOptions());
ref.onDispose(timelineService.dispose);
return timelineService;
}),

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:async/async.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -53,14 +51,14 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
if (isCancelled) {
this.request = null;
unawaited(evict());
PaintingBinding.instance.imageCache.evict(this);
return;
}
try {
final image = await request.load(decode);
if (image == null || isCancelled) {
unawaited(evict());
PaintingBinding.instance.imageCache.evict(this);
return;
}
yield image;
@@ -122,7 +120,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash);
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type);
}
return provider;

View File

@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
@@ -85,7 +84,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
yield* initialImageStream();
if (isCancelled) {
unawaited(evict());
PaintingBinding.instance.imageCache.evict(this);
return;
}
@@ -103,7 +102,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
}
if (isCancelled) {
unawaited(evict());
PaintingBinding.instance.imageCache.evict(this);
return;
}

View File

@@ -1,20 +1,17 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
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/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId;
final String thumbhash;
@@ -41,7 +38,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
headers: ApiService.getRequestHeaders(),
cacheManager: cacheManager,
);
return loadRequest(request, decode);
}
@@ -62,11 +58,11 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId;
final String thumbhash;
final AssetType assetType;
RemoteFullImageProvider({required this.assetId, required this.thumbhash});
RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -90,27 +86,28 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
yield* initialImageStream();
if (isCancelled) {
unawaited(evict());
PaintingBinding.instance.imageCache.evict(this);
return;
}
final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest(
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
cacheManager: cacheManager,
);
yield* loadRequest(request, decode);
yield* loadRequest(previewRequest, decode);
if (isCancelled) {
unawaited(evict());
if (assetType != AssetType.image || !AppSetting.get(Setting.loadOriginal)) {
return;
}
if (AppSetting.get(Setting.loadOriginal)) {
final request = this.request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
yield* loadRequest(request, decode);
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
yield* loadRequest(originalRequest, decode);
}
@override

View File

@@ -1,11 +1,30 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapState {
final ThemeMode themeMode;
final LatLngBounds bounds;
final bool onlyFavorites;
final bool includeArchived;
final bool withPartners;
final int relativeDays;
const MapState({required this.bounds});
const MapState({
this.themeMode = ThemeMode.system,
required this.bounds,
this.onlyFavorites = false,
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
});
@override
bool operator ==(covariant MapState other) {
@@ -15,9 +34,31 @@ class MapState {
@override
int get hashCode => bounds.hashCode;
MapState copyWith({LatLngBounds? bounds}) {
return MapState(bounds: bounds ?? this.bounds);
MapState copyWith({
LatLngBounds? bounds,
ThemeMode? themeMode,
bool? onlyFavorites,
bool? includeArchived,
bool? withPartners,
int? relativeDays,
}) {
return MapState(
bounds: bounds ?? this.bounds,
themeMode: themeMode ?? this.themeMode,
onlyFavorites: onlyFavorites ?? this.onlyFavorites,
includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays,
);
}
TimelineMapOptions toOptions() => TimelineMapOptions(
bounds: bounds,
onlyFavorites: onlyFavorites,
includeArchived: includeArchived,
withPartners: withPartners,
relativeDays: relativeDays,
);
}
class MapStateNotifier extends Notifier<MapState> {
@@ -31,11 +72,50 @@ class MapStateNotifier extends Notifier<MapState> {
return true;
}
void switchTheme(ThemeMode mode) {
// TODO: Remove this line when map theme provider is removed
// Until then, keep both in sync as MapThemeOverride uses map state provider
// ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index);
ref.read(mapStateNotifierProvider.notifier).switchTheme(mode);
state = state.copyWith(themeMode: mode);
}
void switchFavoriteOnly(bool isFavoriteOnly) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapShowFavoriteOnly, isFavoriteOnly);
state = state.copyWith(onlyFavorites: isFavoriteOnly);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void switchIncludeArchived(bool isIncludeArchived) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapIncludeArchived, isIncludeArchived);
state = state.copyWith(includeArchived: isIncludeArchived);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void switchWithPartners(bool isWithPartners) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapwithPartners, isWithPartners);
state = state.copyWith(withPartners: isWithPartners);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void setRelativeTime(int relativeDays) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapRelativeDate, relativeDays);
state = state.copyWith(relativeDays: relativeDays);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
@override
MapState build() => MapState(
// TODO: set default bounds
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
);
MapState build() {
final appSettingsService = ref.read(appSettingsServiceProvider);
return MapState(
themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)],
onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly),
includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived),
withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners),
relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate),
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
);
}
}
// This provider watches the markers from the map service and serves the markers.

View File

@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -51,11 +53,19 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
StreamSubscription? _eventSubscription;
@override
void initState() {
super.initState();
_eventSubscription = EventStream.shared.listen<MapMarkerReloadEvent>(_onEvent);
}
@override
void dispose() {
_debouncer.dispose();
bottomSheetOffset.dispose();
_eventSubscription?.cancel();
super.dispose();
}
@@ -63,6 +73,8 @@ class _DriftMapState extends ConsumerState<DriftMap> {
mapController = controller;
}
void _onEvent(_) => _debouncer.run(() => setBounds(forceReload: true));
Future<void> onMapReady() async {
final controller = mapController;
if (controller == null) {
@@ -98,7 +110,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
);
}
_debouncer.run(setBounds);
_debouncer.run(() => setBounds(forceReload: true));
controller.addListener(onMapMoved);
}
@@ -110,7 +122,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
_debouncer.run(setBounds);
}
Future<void> setBounds() async {
Future<void> setBounds({bool forceReload = false}) async {
final controller = mapController;
if (controller == null || !mounted) {
return;
@@ -127,7 +139,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final bounds = await controller.getVisibleRegion();
unawaited(
_reloadMutex.run(() async {
if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) {
if (mounted && (ref.read(mapStateProvider.notifier).setBounds(bounds) || forceReload)) {
final markers = await ref.read(mapMarkerProvider(bounds).future);
await reloadMarkers(markers);
}
@@ -203,7 +215,7 @@ class _Map extends StatelessWidget {
onMapCreated: onMapCreated,
onStyleLoadedCallback: onMapReady,
attributionButtonPosition: AttributionButtonPosition.topRight,
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
attributionButtonMargins: const Point(8, kToolbarHeight),
),
),
);
@@ -244,7 +256,7 @@ class _DynamicMyLocationButton extends StatelessWidget {
valueListenable: bottomSheetOffset,
builder: (context, offset, child) {
return Positioned(
right: 16,
right: 20,
bottom: context.height * (offset - 0.02) + context.padding.bottom,
child: AnimatedOpacity(
opacity: offset < 0.8 ? 1 : 0,

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends HookConsumerWidget {
const DriftMapSettingsSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
elevation: 0.0,
shadowColor: Colors.transparent,
color: Colors.transparent,
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
MapThemePicker(
themeMode: mapState.themeMode,
onThemeChange: (mode) => ref.read(mapStateProvider.notifier).switchTheme(mode),
),
const Divider(height: 30, thickness: 1),
MapSettingsListTile(
title: "map_settings_only_show_favorites".t(context: context),
selected: mapState.onlyFavorites,
onChanged: (favoriteOnly) => ref.read(mapStateProvider.notifier).switchFavoriteOnly(favoriteOnly),
),
MapSettingsListTile(
title: "map_settings_include_show_archived".t(context: context),
selected: mapState.includeArchived,
onChanged: (includeArchive) =>
ref.read(mapStateProvider.notifier).switchIncludeArchived(includeArchive),
),
MapSettingsListTile(
title: "map_settings_include_show_partners".t(context: context),
selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
),
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}

View File

@@ -2,9 +2,11 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB

View File

@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
@@ -157,11 +156,11 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
]);
if (syncSuccess) {
await Future.wait([
_safeRun(syncCloudIds(_ref), "syncCloudIds"),
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
_resumeBackup();
}),
_resumeBackup(),
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
]);
} else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View File

@@ -1,23 +1,19 @@
import 'dart:async';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart';
@@ -176,15 +172,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
isAdmin: user.isAdmin,
);
// TODO: Temporarily run cloud Id sync here until we have a better place to do it
unawaited(
_ref.read(backgroundSyncProvider).syncRemote().then((success) {
if (success) {
return syncCloudIds(_ref);
}
}),
);
return true;
}

View File

@@ -28,6 +28,9 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
onHashingStart: syncStatusNotifier.startHashJob,
onHashingComplete: syncStatusNotifier.completeHashJob,
onHashingError: syncStatusNotifier.errorHashJob,
onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync,
onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync,
onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync,
);
ref.onDispose(manager.cancel);
return manager;

View File

@@ -1,65 +1,150 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
class CleanupState {
final DateTime? selectedDate;
final List<LocalAsset> assetsToDelete;
final int totalBytes;
final bool isScanning;
final bool isDeleting;
final AssetFilterType filterType;
final AssetKeepType keepMediaType;
final bool keepFavorites;
final Set<String> keepAlbumIds;
const CleanupState({
this.selectedDate,
this.assetsToDelete = const [],
this.totalBytes = 0,
this.isScanning = false,
this.isDeleting = false,
this.filterType = AssetFilterType.all,
this.keepMediaType = AssetKeepType.none,
this.keepFavorites = true,
this.keepAlbumIds = const {},
});
CleanupState copyWith({
DateTime? selectedDate,
List<LocalAsset>? assetsToDelete,
int? totalBytes,
bool? isScanning,
bool? isDeleting,
AssetFilterType? filterType,
AssetKeepType? keepMediaType,
bool? keepFavorites,
Set<String>? keepAlbumIds,
}) {
return CleanupState(
selectedDate: selectedDate ?? this.selectedDate,
assetsToDelete: assetsToDelete ?? this.assetsToDelete,
totalBytes: totalBytes ?? this.totalBytes,
isScanning: isScanning ?? this.isScanning,
isDeleting: isDeleting ?? this.isDeleting,
filterType: filterType ?? this.filterType,
keepMediaType: keepMediaType ?? this.keepMediaType,
keepFavorites: keepFavorites ?? this.keepFavorites,
keepAlbumIds: keepAlbumIds ?? this.keepAlbumIds,
);
}
}
final cleanupProvider = StateNotifierProvider<CleanupNotifier, CleanupState>((ref) {
return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id);
return CleanupNotifier(
ref.watch(cleanupServiceProvider),
ref.watch(currentUserProvider)?.id,
ref.watch(appSettingsServiceProvider),
);
});
class CleanupNotifier extends StateNotifier<CleanupState> {
final CleanupService _cleanupService;
final String? _userId;
final AppSettingsService _appSettingsService;
CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState());
CleanupNotifier(this._cleanupService, this._userId, this._appSettingsService) : super(const CleanupState()) {
_loadPersistedSettings();
}
void _loadPersistedSettings() {
final keepFavorites = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepFavorites);
final keepMediaTypeIndex = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepMediaType);
final keepAlbumIdsString = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepAlbumIds);
final cutoffDaysAgo = _appSettingsService.getSetting(AppSettingsEnum.cleanupCutoffDaysAgo);
final keepMediaType = AssetKeepType.values[keepMediaTypeIndex.clamp(0, AssetKeepType.values.length - 1)];
final keepAlbumIds = keepAlbumIdsString.isEmpty ? <String>{} : keepAlbumIdsString.split(',').toSet();
final selectedDate = cutoffDaysAgo >= 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null;
state = state.copyWith(
keepFavorites: keepFavorites,
keepMediaType: keepMediaType,
keepAlbumIds: keepAlbumIds,
selectedDate: selectedDate,
);
}
void setSelectedDate(DateTime? date) {
state = state.copyWith(selectedDate: date, assetsToDelete: []);
if (date != null) {
final daysAgo = DateTime.now().difference(date).inDays;
_appSettingsService.setSetting(AppSettingsEnum.cleanupCutoffDaysAgo, daysAgo);
}
}
void setFilterType(AssetFilterType filterType) {
state = state.copyWith(filterType: filterType, assetsToDelete: []);
void setKeepMediaType(AssetKeepType keepMediaType) {
state = state.copyWith(keepMediaType: keepMediaType, assetsToDelete: []);
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepMediaType, keepMediaType.index);
}
void setKeepFavorites(bool keepFavorites) {
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepFavorites, keepFavorites);
}
void toggleKeepAlbum(String albumId) {
final newKeepAlbumIds = Set<String>.from(state.keepAlbumIds);
if (newKeepAlbumIds.contains(albumId)) {
newKeepAlbumIds.remove(albumId);
} else {
newKeepAlbumIds.add(albumId);
}
state = state.copyWith(keepAlbumIds: newKeepAlbumIds, assetsToDelete: []);
_persistExcludedAlbumIds(newKeepAlbumIds);
}
void setExcludedAlbumIds(Set<String> albumIds) {
state = state.copyWith(keepAlbumIds: albumIds, assetsToDelete: []);
_persistExcludedAlbumIds(albumIds);
}
void _persistExcludedAlbumIds(Set<String> albumIds) {
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepAlbumIds, albumIds.join(','));
}
void cleanupStaleAlbumIds(Set<String> existingAlbumIds) {
final staleIds = state.keepAlbumIds.difference(existingAlbumIds);
if (staleIds.isNotEmpty) {
final cleanedIds = state.keepAlbumIds.intersection(existingAlbumIds);
state = state.copyWith(keepAlbumIds: cleanedIds);
_persistExcludedAlbumIds(cleanedIds);
}
}
void applyDefaultAlbumSelections(List<(String id, String name)> albums) {
final isInitialized = _appSettingsService.getSetting(AppSettingsEnum.cleanupDefaultsInitialized);
if (isInitialized) return;
final toKeep = _cleanupService.getDefaultKeepAlbumIds(albums);
if (toKeep.isNotEmpty) {
final keepAlbumIds = {...state.keepAlbumIds, ...toKeep};
state = state.copyWith(keepAlbumIds: keepAlbumIds);
_persistExcludedAlbumIds(keepAlbumIds);
}
_appSettingsService.setSetting(AppSettingsEnum.cleanupDefaultsInitialized, true);
}
Future<void> scanAssets() async {
@@ -69,13 +154,15 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
state = state.copyWith(isScanning: true);
try {
final assets = await _cleanupService.getRemovalCandidates(
final result = await _cleanupService.getRemovalCandidates(
_userId,
state.selectedDate!,
filterType: state.filterType,
keepMediaType: state.keepMediaType,
keepFavorites: state.keepFavorites,
keepAlbumIds: state.keepAlbumIds,
);
state = state.copyWith(assetsToDelete: assets, isScanning: false);
state = state.copyWith(assetsToDelete: result.assets, totalBytes: result.totalBytes, isScanning: false);
} catch (e) {
state = state.copyWith(isScanning: false);
rethrow;
@@ -101,6 +188,7 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
}
void reset() {
state = const CleanupState();
// Only reset transient state, keep the persisted filter settings
state = state.copyWith(selectedDate: null, assetsToDelete: [], isScanning: false, isDeleting: false);
}
}

View File

@@ -1,148 +1,25 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
// ignore: implementation_imports
import 'package:flutter_cache_manager/src/cache_store.dart';
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
abstract class RemoteCacheManager extends CacheManager {
static final _log = Logger('RemoteCacheManager');
RemoteCacheManager.custom(super.config, CacheStore store)
// Unfortunately, CacheStore is not a public API
// ignore: invalid_use_of_visible_for_testing_member
: super.custom(cacheStore: store);
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
});
// Unlike `putFileStream`, this method handles request cancellation,
// does not make a (slow) DB call checking if the file is already cached,
// does not synchronously check if a file exists,
// and deletes the file on cancellation without making these checks again.
Future<void> putStreamedFileToStore(
CacheStore store,
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) async {
final path = '${const Uuid().v1()}.$fileExtension';
final file = await store.fileSystem.createFile(path);
final sink = file.openWrite();
try {
await source.listen(sink.add, cancelOnError: true).asFuture();
} catch (e) {
try {
await sink.close();
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
try {
await sink.flush();
await sink.close();
} catch (e) {
try {
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
final cacheObject = CacheObject(
url,
key: key,
relativePath: path,
validTill: DateTime.now().add(maxAge),
eTag: eTag,
);
try {
await store.putFile(cacheObject);
} catch (e) {
try {
await file.delete();
} catch (e) {
_log.severe('Failed to delete untracked cache file: $e');
}
}
}
}
class RemoteImageCacheManager extends RemoteCacheManager {
class RemoteImageCacheManager extends CacheManager {
static const key = 'remoteImageCacheKey';
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
static final _store = CacheStore(_config);
factory RemoteImageCacheManager() {
return _instance;
}
RemoteImageCacheManager._() : super.custom(_config, _store);
@override
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) {
return putStreamedFileToStore(
_store,
url,
source,
key: key,
eTag: eTag,
maxAge: maxAge,
fileExtension: fileExtension,
);
}
RemoteImageCacheManager._() : super(_config);
}
/// The cache manager for full size images [ImmichRemoteImageProvider]
class RemoteThumbnailCacheManager extends RemoteCacheManager {
class RemoteThumbnailCacheManager extends CacheManager {
static const key = 'remoteThumbnailCacheKey';
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
static final _store = CacheStore(_config);
factory RemoteThumbnailCacheManager() {
return _instance;
}
RemoteThumbnailCacheManager._() : super.custom(_config, _store);
@override
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) {
return putStreamedFileToStore(
_store,
url,
source,
key: key,
eTag: eTag,
maxAge: maxAge,
fileExtension: fileExtension,
);
}
RemoteThumbnailCacheManager._() : super(_config);
}

View File

@@ -359,6 +359,22 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> updateRating(ActionSource source, int rating) async {
final ids = _getRemoteIdsForSource(source);
if (ids.length != 1) {
_logger.warning('updateRating called with multiple assets, expected single asset');
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for rating update');
}
try {
final isUpdated = await _service.updateRating(ids.first, rating);
return ActionResult(count: 1, success: isUpdated);
} catch (error, stack) {
_logger.severe('Failed to update rating for asset', error, stack);
return ActionResult(count: 1, success: false, error: error.toString());
}
}
Future<ActionResult> stack(String userId, ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {

View File

@@ -1,7 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/domain/services/map.service.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final mapRepositoryProvider = Provider<DriftMapRepository>((ref) => DriftMapRepository(ref.watch(driftProvider)));
@@ -13,7 +15,11 @@ final mapServiceProvider = Provider<MapService>(
throw Exception('User must be logged in to access map');
}
final mapService = ref.watch(mapFactoryProvider).remote(user.id);
final users = ref.watch(mapStateProvider).withPartners
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions());
return mapService;
},
// Empty dependencies to inform the framework that this provider

View File

@@ -4,7 +4,8 @@ import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/thumbnail_api.g.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:immich_mobile/platform/remote_image_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
@@ -16,4 +17,6 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
final thumbnailApi = ThumbnailApi();
final localImageApi = LocalImageApi();
final remoteImageApi = RemoteImageApi();

View File

@@ -1,7 +1,22 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
);
final userMetadataProvider = FutureProvider<List<UserMetadata>>((ref) async {
final repository = ref.watch(userMetadataRepository);
final user = ref.watch(currentUserProvider);
if (user == null) return [];
return repository.getUserMetadata(user.id);
});
final userMetadataPreferencesProvider = FutureProvider<Preferences?>((ref) async {
final metadataList = await ref.watch(userMetadataProvider.future);
final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null);
return metadataWithPrefs.preferences;
});

View File

@@ -21,6 +21,7 @@ class SyncStatusState {
final SyncStatus remoteSyncStatus;
final SyncStatus localSyncStatus;
final SyncStatus hashJobStatus;
final SyncStatus cloudIdSyncStatus;
final String? errorMessage;
@@ -28,6 +29,7 @@ class SyncStatusState {
this.remoteSyncStatus = SyncStatus.idle,
this.localSyncStatus = SyncStatus.idle,
this.hashJobStatus = SyncStatus.idle,
this.cloudIdSyncStatus = SyncStatus.idle,
this.errorMessage,
});
@@ -35,12 +37,14 @@ class SyncStatusState {
SyncStatus? remoteSyncStatus,
SyncStatus? localSyncStatus,
SyncStatus? hashJobStatus,
SyncStatus? cloudIdSyncStatus,
String? errorMessage,
}) {
return SyncStatusState(
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
localSyncStatus: localSyncStatus ?? this.localSyncStatus,
hashJobStatus: hashJobStatus ?? this.hashJobStatus,
cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@@ -48,6 +52,7 @@ class SyncStatusState {
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
bool get isHashing => hashJobStatus == SyncStatus.syncing;
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
@override
bool operator ==(Object other) {
@@ -56,11 +61,12 @@ class SyncStatusState {
other.remoteSyncStatus == remoteSyncStatus &&
other.localSyncStatus == localSyncStatus &&
other.hashJobStatus == hashJobStatus &&
other.cloudIdSyncStatus == cloudIdSyncStatus &&
other.errorMessage == errorMessage;
}
@override
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage);
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
}
class SyncStatusNotifier extends Notifier<SyncStatusState> {
@@ -71,6 +77,7 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
remoteSyncStatus: SyncStatus.idle,
localSyncStatus: SyncStatus.idle,
hashJobStatus: SyncStatus.idle,
cloudIdSyncStatus: SyncStatus.idle,
);
}
@@ -109,6 +116,18 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
void startHashJob() => setHashJobStatus(SyncStatus.syncing);
void completeHashJob() => setHashJobStatus(SyncStatus.success);
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error);
///
/// Cloud ID Sync Job
///
void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) {
state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
}
void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing);
void completeCloudIdSync() => setCloudIdSyncStatus(SyncStatus.success);
void errorCloudIdSync(String error) => setCloudIdSyncStatus(SyncStatus.error, error);
}
final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new);

View File

@@ -80,8 +80,8 @@ class AssetApiRepository extends ApiRepository {
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
}
Future<Response> downloadAsset(String id) {
return _api.downloadAssetWithHttpInfo(id);
Future<Response> downloadAsset(String id, {required bool edited}) {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
@@ -101,6 +101,10 @@ class AssetApiRepository extends ApiRepository {
Future<void> updateDescription(String assetId, String description) {
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
}
Future<void> updateRating(String assetId, int rating) {
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
}
}
extension on StackResponseDto {

View File

@@ -112,17 +112,23 @@ class AssetMediaRepository {
: asset is RemoteAsset
? asset.localId
: null;
if (localId != null) {
if (localId != null && !asset.isEdited) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path));
if (CurrentPlatform.isIOS) {
tempFiles.add(f);
}
} else if (asset is RemoteAsset) {
} else {
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset");
continue;
}
final tempDir = await getTemporaryDirectory();
final name = asset.name;
final tempFile = await File('${tempDir.path}/$name').create();
final res = await _assetApiRepository.downloadAsset(asset.id);
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true);
if (res.statusCode != 200) {
_log.severe("Download for $name failed", res.toLoggerString());
@@ -132,9 +138,6 @@ class AssetMediaRepository {
await tempFile.writeAsBytes(res.bodyBytes);
downloadedXFiles.add(XFile(tempFile.path));
tempFiles.add(tempFile);
} else {
_log.warning("Asset type not supported for sharing: $asset");
continue;
}
}

View File

@@ -214,6 +214,14 @@ class ActionService {
return true;
}
Future<bool> updateRating(String assetId, int rating) async {
// update remote first, then local to ensure consistency
await _assetApiRepository.updateRating(assetId, rating);
await _remoteAssetRepository.updateRating(assetId, rating);
return true;
}
Future<void> stack(String userId, List<String> remoteIds) async {
final stack = await _assetApiRepository.stack(remoteIds);
await _remoteAssetRepository.stack(userId, stack);

View File

@@ -54,7 +54,12 @@ enum AppSettingsEnum<T> {
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
cleanupKeepFavorites<bool>(StoreKey.cleanupKeepFavorites, null, true),
cleanupKeepMediaType<int>(StoreKey.cleanupKeepMediaType, null, 0),
cleanupKeepAlbumIds<String>(StoreKey.cleanupKeepAlbumIds, null, ""),
cleanupCutoffDaysAgo<int>(StoreKey.cleanupCutoffDaysAgo, null, -1),
cleanupDefaultsInitialized<bool>(StoreKey.cleanupDefaultsInitialized, null, false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -1,6 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -15,17 +14,19 @@ class CleanupService {
const CleanupService(this._localAssetRepository, this._assetMediaRepository);
Future<List<LocalAsset>> getRemovalCandidates(
Future<RemovalCandidatesResult> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
AssetKeepType keepMediaType = AssetKeepType.none,
bool keepFavorites = true,
Set<String> keepAlbumIds = const {},
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepMediaType: keepMediaType,
keepFavorites: keepFavorites,
keepAlbumIds: keepAlbumIds,
);
}
@@ -42,4 +43,18 @@ class CleanupService {
return 0;
}
/// Returns album IDs that should be kept by default (e.g., messaging app albums)
Set<String> getDefaultKeepAlbumIds(List<(String id, String name)> albums) {
const messagingApps = ['whatsapp', 'telegram', 'signal', 'messenger', 'viber', 'wechat', 'line'];
final toKeep = <String>{};
for (final (id, name) in albums) {
final albumName = name.toLowerCase();
if (messagingApps.any((app) => albumName.contains(app))) {
toKeep.add(id);
}
}
return toKeep;
}
}

View File

@@ -21,6 +21,7 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
@@ -106,5 +107,7 @@ abstract final class Bootstrap {
storeRepository: storeRepo,
shouldBuffer: shouldBufferLogs,
);
await NetworkRepository.init();
}
}

View File

@@ -19,7 +19,7 @@ String formatBytes(int bytes) {
String formatHumanReadableBytes(int bytes, int decimals) {
if (bytes <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
const suffixes = ["B", "KiB", "MiB", "GiB", "TiB"];
var i = (log(bytes) / log(1024)).floor();
return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}';
}

View File

@@ -88,6 +88,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
if (version < 20 && Store.isBetaTimelineEnabled) {
await _syncLocalAlbumIsIosSharedAlbum(drift);
await _backfillAssetExifWidthHeight(drift);
}
if (targetVersion >= 12) {
@@ -281,6 +282,22 @@ Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
}
}
Future<void> _backfillAssetExifWidthHeight(Drift db) async {
try {
await db.customStatement('''
UPDATE remote_exif_entity AS remote_exif
SET width = asset.width,
height = asset.height
FROM remote_asset_entity AS asset
WHERE remote_exif.asset_id = asset.id;
''');
dPrint(() => "[MIGRATION] Successfully backfilled asset exif width and height");
} catch (error) {
dPrint(() => "[MIGRATION] Error while backfilling asset exif width and height: $error");
}
}
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
try {
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();

View File

@@ -16,6 +16,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart';
@@ -87,6 +88,14 @@ class ImmichAppBarDialog extends HookConsumerWidget {
return buildActionButton(Icons.settings_outlined, "settings", () => context.pushRoute(const SettingsRoute()));
}
buildFreeUpSpaceButton() {
return buildActionButton(
Icons.cleaning_services_outlined,
"free_up_space",
() => context.pushRoute(SettingsSubRoute(section: SettingSection.freeUpSpace)),
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
@@ -271,6 +280,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
const AppBarServerInfo(),
if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildFreeUpSpaceButton(),
buildSettingButton(),
buildSignOutButton(),
buildFooter(),

View File

@@ -1,4 +1,3 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -14,7 +13,7 @@ class MapSettingsListTile extends StatelessWidget {
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
activeThumbColor: context.primaryColor,
title: Text(title, style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
title: Text(title, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5)),
value: selected,
onChanged: onChanged,
);

View File

@@ -1,5 +1,7 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class MapTimeDropDown extends StatelessWidget {
final int relativeTime;
@@ -11,41 +13,47 @@ class MapTimeDropDown extends StatelessWidget {
Widget build(BuildContext context) {
final now = DateTime.now();
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text("date_range".tr(), style: const TextStyle(fontWeight: FontWeight.bold)),
),
LayoutBuilder(
builder: (_, constraints) => DropdownMenu(
width: constraints.maxWidth * 0.9,
enableSearch: false,
enableFilter: false,
initialSelection: relativeTime,
onSelected: (value) => onTimeChange(value!),
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "all".tr()),
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".tr()),
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
DropdownMenuEntry(value: 30, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"})),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_year".tr(),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_years".tr(namedArgs: {'years': "3"}),
),
],
return Padding(
padding: const EdgeInsets.only(left: 16, right: 28.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"date_range".t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
),
],
Flexible(
child: DropdownMenu(
enableSearch: false,
enableFilter: false,
initialSelection: relativeTime,
onSelected: (value) => onTimeChange(value!),
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "all".t(context: context)),
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".t(context: context)),
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
DropdownMenuEntry(
value: 30,
label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"}),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_year".t(context: context),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_years".t(args: {'years': "3"}),
),
],
),
),
],
),
);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -18,9 +18,9 @@ class MapThemePicker extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 20),
child: Center(
child: Text(
"map_settings_theme_settings",
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
).tr(),
"map_settings_theme_settings".t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
),
),
Row(

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart';
/// The interface in which controllers will be implemented.
///
@@ -62,6 +63,9 @@ abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
/// The scale factor to transform the child (image or a customChild).
late double? scale;
double? get initialScale;
ScaleBoundaries? scaleBoundaries;
/// Nevermind this method :D, look away
void setScaleInvisibly(double? scale);
@@ -141,6 +145,9 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
late StreamController<PhotoViewControllerValue> _outputCtrl;
@override
ScaleBoundaries? scaleBoundaries;
late void Function(Offset)? _animatePosition;
late void Function(double)? _animateScale;
late void Function(double)? _animateRotation;
@@ -311,4 +318,7 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
}
_valueNotifier.value = newValue;
}
@override
double? get initialScale => scaleBoundaries?.initialScale ?? initial.scale;
}

View File

@@ -108,6 +108,17 @@ class _ImageWrapperState extends State<ImageWrapper> {
}
}
// Should be called only when _imageSize is not null
ScaleBoundaries get scaleBoundaries {
return ScaleBoundaries(
widget.minScale ?? 0.0,
widget.maxScale ?? double.infinity,
widget.initialScale ?? PhotoViewComputedScale.contained,
widget.outerSize,
_imageSize!,
);
}
// retrieve image from the provider
void _resolveImage() {
final ImageStream newStream = widget.imageProvider.resolve(const ImageConfiguration());
@@ -133,6 +144,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
_lastStack = null;
_didLoadSynchronously = synchronousCall;
widget.controller.scaleBoundaries = scaleBoundaries;
}
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
@@ -204,14 +216,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
);
}
final scaleBoundaries = ScaleBoundaries(
widget.minScale ?? 0.0,
widget.maxScale ?? double.infinity,
widget.initialScale ?? PhotoViewComputedScale.contained,
widget.outerSize,
_imageSize!,
);
return PhotoViewCore(
imageProvider: widget.imageProvider,
backgroundDecoration: widget.backgroundDecoration,

View File

@@ -55,6 +55,7 @@ class ExploreGrid extends StatelessWidget {
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
),
),

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
class StarRatingPicker extends HookWidget {
const StarRatingPicker({super.key, required this.onSelect, this.filter});
final Function(SearchRatingFilter) onSelect;
final SearchRatingFilter? filter;
@override
Widget build(BuildContext context) {
final selectedRating = useState(filter);
return RadioGroup(
groupValue: selectedRating.value?.rating,
onChanged: (int? newValue) {
if (newValue == null) return;
final newFilter = SearchRatingFilter(rating: newValue);
selectedRating.value = newFilter;
onSelect(newFilter);
},
child: Column(
children: List.generate(
6,
(index) => RadioListTile<int>(
key: Key("star_$index"),
title: Text('rating_count'.t(args: {'count': (index)})),
value: index,
),
),
),
);
}
}

View File

@@ -8,10 +8,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
@@ -153,6 +155,44 @@ class AdvancedSettings extends HookConsumerWidget {
);
},
),
ListTile(
title: Text("advanced_settings_clear_image_cache".tr(), style: const TextStyle(fontWeight: FontWeight.w500)),
leading: const Icon(Icons.playlist_remove_rounded),
onTap: () async {
final int clearedBytes;
try {
clearedBytes = await remoteImageApi.clearCache();
} catch (e) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
"advanced_settings_clear_image_cache_error".tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.themeData.colorScheme.error),
),
),
);
return;
}
if (clearedBytes < 0) {
return;
}
// iOS always returns a small non-zero value
final clearedMB = clearedBytes < (256 * 1024) ? "0 MiB" : formatHumanReadableBytes(clearedBytes, 2);
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
"advanced_settings_clear_image_cache_success".tr(namedArgs: {'size': clearedMB}),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
},
),
const SizedBox(height: 60),
];
return SettingsSubPageScaffold(settings: advancedSettings);

View File

@@ -3,13 +3,16 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/cleanup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
class FreeUpSpaceSettings extends ConsumerStatefulWidget {
const FreeUpSpaceSettings({super.key});
@@ -21,6 +24,25 @@ class FreeUpSpaceSettings extends ConsumerStatefulWidget {
class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
CleanupStep _currentStep = CleanupStep.selectDate;
bool _hasScanned = false;
bool _isKeepSettingsExpanded = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeAlbumDefaults();
});
}
Future<void> _initializeAlbumDefaults() async {
final albums = await ref.read(localAlbumProvider.future);
final existingAlbumIds = albums.map((a) => a.id).toSet();
final albumsWithNames = albums.map((a) => (a.id, a.name)).toList();
final notifier = ref.read(cleanupProvider.notifier);
notifier.applyDefaultAlbumSelections(albumsWithNames);
notifier.cleanupStaleAlbumIds(existingAlbumIds);
}
void _resetState() {
ref.read(cleanupProvider.notifier).reset();
@@ -35,20 +57,16 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
}
if (state.selectedDate != null) {
return CleanupStep.filterOptions;
return CleanupStep.scan;
}
return CleanupStep.selectDate;
}
void _goToFiltersStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.filterOptions);
}
void _goToScanStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.scan);
_scanAssets();
}
void _setPresetDate(int daysAgo) {
@@ -83,9 +101,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
if (picked != null) {
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
setState(() => _hasScanned = false);
}
}
void _onKeepSettingsChanged() {
setState(() {
_hasScanned = false;
_currentStep = CleanupStep.scan;
});
}
Future<void> _scanAssets() async {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
@@ -127,6 +153,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
context: context,
builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount),
);
if (mounted) {
context.router.popUntilRoot();
}
return;
}
setState(() => _currentStep = CleanupStep.selectDate);
@@ -145,6 +176,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
final subtitleStyle = context.textTheme.bodyMedium!.copyWith(
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
);
StepStyle styleForState(StepState stepState, {bool isDestructive = false}) {
switch (stepState) {
case StepState.complete:
@@ -174,28 +206,38 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
}
final step1State = hasDate ? StepState.complete : StepState.indexed;
final step2State = hasDate ? StepState.complete : StepState.disabled;
final step3State = hasAssets
final step2State = hasAssets
? StepState.complete
: hasDate
? StepState.indexed
: StepState.disabled;
final step4State = hasAssets ? StepState.indexed : StepState.disabled;
final step3State = hasAssets ? StepState.indexed : StepState.disabled;
String getFilterSubtitle() {
final hasKeepSettings =
state.keepFavorites || state.keepAlbumIds.isNotEmpty || state.keepMediaType != AssetKeepType.none;
String getKeepSettingsSummary() {
final parts = <String>[];
switch (state.filterType) {
case AssetFilterType.all:
parts.add('all'.t(context: context));
case AssetFilterType.photosOnly:
parts.add('photos_only'.t(context: context));
case AssetFilterType.videosOnly:
parts.add('videos_only'.t(context: context));
if (state.keepMediaType == AssetKeepType.photosOnly) {
parts.add('all_photos'.t(context: context));
} else if (state.keepMediaType == AssetKeepType.videosOnly) {
parts.add('all_videos'.t(context: context));
}
if (state.keepFavorites) {
parts.add('keep_favorites'.t(context: context));
parts.add('favorites'.t(context: context).toLowerCase());
}
return parts.join('');
if (state.keepAlbumIds.isNotEmpty) {
parts.add('keep_albums_count'.t(context: context, args: {'count': state.keepAlbumIds.length.toString()}));
}
if (parts.isEmpty) {
return 'none'.t(context: context);
}
return parts.join(', ');
}
return PopScope(
@@ -220,6 +262,126 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
),
),
// Keep on device settings card
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
side: BorderSide(
color: hasKeepSettings
? context.colorScheme.primary.withValues(alpha: 0.5)
: context.colorScheme.outlineVariant,
width: hasKeepSettings ? 1.5 : 1,
),
),
color: hasKeepSettings
? context.colorScheme.primaryContainer.withValues(alpha: 0.15)
: context.colorScheme.surfaceContainerLow,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: _isKeepSettingsExpanded,
onExpansionChanged: (expanded) {
setState(() => _isKeepSettingsExpanded = expanded);
},
leading: Icon(
hasKeepSettings ? Icons.bookmark : Icons.bookmark_border,
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
),
title: Text(
'keep_on_device'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: hasKeepSettings ? context.colorScheme.primary : null,
),
),
subtitle: Text(
hasKeepSettings
? 'keeping'.t(context: context, args: {'items': getKeepSettingsSummary()})
: 'keep_on_device_hint'.t(context: context),
style: context.textTheme.bodySmall?.copyWith(
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
),
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('keep_description'.t(context: context), style: subtitleStyle),
const SizedBox(height: 4),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'keep_favorites'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
value: state.keepFavorites,
onChanged: (value) {
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
_onKeepSettingsChanged();
},
),
const SizedBox(height: 8),
_KeepAlbumsSection(
albumIds: state.keepAlbumIds,
onAlbumToggled: (albumId) {
ref.read(cleanupProvider.notifier).toggleKeepAlbum(albumId);
_onKeepSettingsChanged();
},
),
const SizedBox(height: 16),
Text(
'always_keep'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
const SizedBox(height: 4),
SegmentedButton<AssetKeepType>(
showSelectedIcon: false,
segments: [
const ButtonSegment(value: AssetKeepType.none, label: Text('')),
ButtonSegment(
value: AssetKeepType.photosOnly,
label: Text('photos'.t(context: context)),
icon: const Icon(Icons.photo),
),
ButtonSegment(
value: AssetKeepType.videosOnly,
label: Text('videos'.t(context: context)),
icon: const Icon(Icons.videocam),
),
],
selected: {state.keepMediaType},
onSelectionChanged: (selection) {
ref.read(cleanupProvider.notifier).setKeepMediaType(selection.first);
_onKeepSettingsChanged();
},
),
if (state.keepMediaType != AssetKeepType.none) ...[
const SizedBox(height: 8),
Text(
state.keepMediaType == AssetKeepType.photosOnly
? 'always_keep_photos_hint'.t(context: context)
: 'always_keep_videos_hint'.t(context: context),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
),
),
),
const SizedBox(height: 8),
Stepper(
physics: const NeverScrollableScrollPhysics(),
currentStep: _currentStep.index,
@@ -314,7 +476,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: hasDate ? () => _goToFiltersStep() : null,
onPressed: hasDate ? _goToScanStep : null,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
@@ -325,11 +487,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
state: step1State,
),
// Step 2: Select Filter Options
// Step 2: Scan Assets
Step(
stepStyle: styleForState(step2State),
title: Text(
'filter_options'.t(context: context),
'scan'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step2State == StepState.complete
@@ -339,96 +501,20 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
: context.colorScheme.onSurface,
),
),
subtitle: hasDate
? Text(
getFilterSubtitle(),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
)
: null,
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('cleanup_filter_description'.t(context: context), style: subtitleStyle),
const SizedBox(height: 16),
SegmentedButton<AssetFilterType>(
segments: [
ButtonSegment(
value: AssetFilterType.all,
label: Text('all'.t(context: context)),
icon: const Icon(Icons.photo_library),
),
ButtonSegment(
value: AssetFilterType.photosOnly,
label: Text('photos'.t(context: context)),
icon: const Icon(Icons.photo),
),
ButtonSegment(
value: AssetFilterType.videosOnly,
label: Text('videos'.t(context: context)),
icon: const Icon(Icons.videocam),
),
],
selected: {state.filterType},
onSelectionChanged: (selection) {
ref.read(cleanupProvider.notifier).setFilterType(selection.first);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'keep_favorites'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
subtitle: Text(
'keep_favorites_description'.t(context: context),
style: context.textTheme.bodyMedium!.copyWith(
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
),
),
value: state.keepFavorites,
onChanged: (value) {
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _goToScanStep,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
],
),
isActive: hasDate,
state: step2State,
),
// Step 3: Scan Assets
Step(
stepStyle: styleForState(step3State),
title: Text(
'scan'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step3State == StepState.complete
? context.colorScheme.primary
: step3State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.onSurface,
),
),
subtitle: _hasScanned
? Text(
'cleanup_found_assets'.t(
context: context,
args: {'count': state.assetsToDelete.length.toString()},
),
state.totalBytes > 0
? 'cleanup_found_assets_with_size'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'size': formatBytes(state.totalBytes),
},
)
: 'cleanup_found_assets'.t(
context: context,
args: {'count': state.assetsToDelete.length.toString()},
),
style: context.textTheme.bodyMedium?.copyWith(
color: state.assetsToDelete.isNotEmpty
? context.colorScheme.primary
@@ -503,17 +589,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
],
),
isActive: hasDate,
state: step3State,
state: step2State,
),
// Step 4: Delete Assets
// Step 3: Delete Assets
Step(
stepStyle: styleForState(step4State, isDestructive: true),
stepStyle: styleForState(step3State, isDestructive: true),
title: Text(
'move_to_device_trash'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step4State == StepState.disabled
color: step3State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.error,
),
@@ -529,15 +615,20 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)),
),
child: hasAssets
? Text(
'cleanup_step4_summary'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'date': DateFormat.yMMMd().format(state.selectedDate!),
},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'cleanup_step4_summary'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'date': DateFormat.yMMMd().format(state.selectedDate!),
},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
],
)
: null,
),
@@ -573,10 +664,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
],
),
isActive: hasAssets,
state: step4State,
state: step3State,
),
],
),
const SizedBox(height: 60),
],
),
),
@@ -701,3 +793,107 @@ class _DatePresetCard extends StatelessWidget {
);
}
}
class _KeepAlbumsSection extends ConsumerWidget {
final Set<String> albumIds;
final ValueChanged<String> onAlbumToggled;
const _KeepAlbumsSection({required this.albumIds, required this.onAlbumToggled});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumsAsync = ref.watch(localAlbumProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'keep_albums'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
const SizedBox(height: 8),
albumsAsync.when(
loading: () => const Center(
child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (error, stack) => Text(
'error_loading_albums'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
),
data: (albums) {
if (albums.isEmpty) {
return Text(
'no_albums_found'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
);
}
return Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.outlineVariant),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
constraints: const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: ListView.builder(
shrinkWrap: true,
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
final isSelected = albumIds.contains(album.id);
return _AlbumTile(album: album, isSelected: isSelected, onToggle: () => onAlbumToggled(album.id));
},
),
),
);
},
),
if (albumIds.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'keep_albums_count'.t(context: context, args: {'count': albumIds.length.toString()}),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
);
}
}
class _AlbumTile extends StatelessWidget {
final LocalAlbum album;
final bool isSelected;
final VoidCallback onToggle;
const _AlbumTile({required this.album, required this.isSelected, required this.onToggle});
@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
leading: Icon(
isSelected ? Icons.check_circle : Icons.circle_outlined,
color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
size: 20,
),
title: Text(
album.name,
style: context.textTheme.bodyMedium?.copyWith(color: isSelected ? context.colorScheme.primary : null),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
album.assetCount.toString(),
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
onTap: onToggle,
);
}
}

View File

@@ -7,12 +7,14 @@ build:
pigeon:
dart run pigeon --input pigeon/native_sync_api.dart
dart run pigeon --input pigeon/thumbnail_api.dart
dart run pigeon --input pigeon/local_image_api.dart
dart run pigeon --input pigeon/remote_image_api.dart
dart run pigeon --input pigeon/background_worker_api.dart
dart run pigeon --input pigeon/background_worker_lock_api.dart
dart run pigeon --input pigeon/connectivity_api.dart
dart format lib/platform/native_sync_api.g.dart
dart format lib/platform/thumbnail_api.g.dart
dart format lib/platform/local_image_api.g.dart
dart format lib/platform/remote_image_api.g.dart
dart format lib/platform/background_worker_api.g.dart
dart format lib/platform/background_worker_lock_api.g.dart
dart format lib/platform/connectivity_api.g.dart

View File

@@ -1691,7 +1691,7 @@ class AssetsApi {
/// View asset thumbnail
///
/// Retrieve the thumbnail image for the specified asset.
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
///
/// Note: This method returns the HTTP [Response].
///
@@ -1747,7 +1747,7 @@ class AssetsApi {
/// View asset thumbnail
///
/// Retrieve the thumbnail image for the specified asset.
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
///
/// Parameters:
///

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