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>
This commit is contained in:
Mert
2026-01-24 14:34:29 -05:00
committed by GitHub
parent 9219d559a0
commit 1803692eab
44 changed files with 1881 additions and 556 deletions

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

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

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

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

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

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,5 +1,3 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
@@ -7,14 +5,12 @@ 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 +37,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,7 +57,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId;
final String thumbhash;
@@ -90,7 +84,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
yield* initialImageStream();
if (isCancelled) {
unawaited(evict());
PaintingBinding.instance.imageCache.evict(this);
return;
}
@@ -98,12 +92,11 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
cacheManager: cacheManager,
);
yield* loadRequest(request, decode);
if (isCancelled) {
unawaited(evict());
PaintingBinding.instance.imageCache.evict(this);
return;
}

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

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

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

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

@@ -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,43 @@ 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),
];