feat(mobile): show local assets (#905)

* introduce Asset as composition of AssetResponseDTO and AssetEntity

* filter out duplicate assets (that are both local and remote, take only remote for now)

* only allow remote images to be added to albums

* introduce ImmichImage to render Asset using local or remote data

* optimized deletion of local assets

* local video file playback

* allow multiple methods to wait on background service finished

* skip local assets when adding to album from home screen

* fix and optimize delete

* show gray box placeholder for local assets

* add comments

* fix bug: duplicate assets in state after onNewAssetUploaded
This commit is contained in:
Fynn Petersen-Frey
2022-11-08 18:00:24 +01:00
committed by GitHub
parent 99da181cfc
commit 1633af7af6
41 changed files with 830 additions and 514 deletions

View File

@@ -0,0 +1,117 @@
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
/// Asset (online or local)
class Asset {
Asset.remote(this.remote) {
local = null;
}
Asset.local(this.local) {
remote = null;
}
late final AssetResponseDto? remote;
late final AssetEntity? local;
bool get isRemote => remote != null;
bool get isLocal => local != null;
String get deviceId =>
isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey);
String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id;
String get id => isLocal ? local!.id : remote!.id;
double? get latitude =>
isLocal ? local!.latitude : remote!.exifInfo?.latitude?.toDouble();
double? get longitude =>
isLocal ? local!.longitude : remote!.exifInfo?.longitude?.toDouble();
DateTime get createdAt =>
isLocal ? local!.createDateTime : DateTime.parse(remote!.createdAt);
bool get isImage => isLocal
? local!.type == AssetType.image
: remote!.type == AssetTypeEnum.IMAGE;
String get duration => isRemote
? remote!.duration
: Duration(seconds: local!.duration).toString();
/// use only for tests
set createdAt(DateTime val) {
if (isRemote) {
remote!.createdAt = val.toIso8601String();
}
}
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (isLocal) {
json["local"] = _assetEntityToJson(local!);
} else {
json["remote"] = remote!.toJson();
}
return json;
}
static Asset? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
final l = json["local"];
if (l != null) {
return Asset.local(_assetEntityFromJson(l));
} else {
return Asset.remote(AssetResponseDto.fromJson(json["remote"]));
}
}
return null;
}
}
Map<String, dynamic> _assetEntityToJson(AssetEntity a) {
final json = <String, dynamic>{};
json["id"] = a.id;
json["typeInt"] = a.typeInt;
json["width"] = a.width;
json["height"] = a.height;
json["duration"] = a.duration;
json["orientation"] = a.orientation;
json["isFavorite"] = a.isFavorite;
json["title"] = a.title;
json["createDateSecond"] = a.createDateSecond;
json["modifiedDateSecond"] = a.modifiedDateSecond;
json["latitude"] = a.latitude;
json["longitude"] = a.longitude;
json["mimeType"] = a.mimeType;
json["subtype"] = a.subtype;
return json;
}
AssetEntity? _assetEntityFromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEntity(
id: json["id"],
typeInt: json["typeInt"],
width: json["width"],
height: json["height"],
duration: json["duration"],
orientation: json["orientation"],
isFavorite: json["isFavorite"],
title: json["title"],
createDateSecond: json["createDateSecond"],
modifiedDateSecond: json["modifiedDateSecond"],
latitude: json["latitude"],
longitude: json["longitude"],
mimeType: json["mimeType"],
subtype: json["subtype"],
);
}
return null;
}

View File

@@ -1,18 +1,23 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
class AssetNotifier extends StateNotifier<List<Asset>> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;
final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
AssetNotifier(this._assetService, this._assetCacheService) : super([]);
@@ -21,29 +26,38 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
}
getAllAsset() async {
final stopwatch = Stopwatch();
if (await _assetCacheService.isValid() && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (_getAllAssetInProgress || _deleteInProgress) {
// guard against multiple calls to this method while it's still working
return;
}
final stopwatch = Stopwatch();
try {
_getAllAssetInProgress = true;
final bool isCacheValid = await _assetCacheService.isValid();
if (isCacheValid && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint(
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
stopwatch.start();
var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
state = allAssets;
} finally {
_getAllAssetInProgress = false;
}
debugPrint("[getAllAsset] setting new asset state");
stopwatch.start();
var allAssets = await _assetService.getAllAsset();
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (allAssets != null) {
state = allAssets;
stopwatch.start();
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
}
clearAllAsset() {
@@ -52,80 +66,113 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
}
onNewAssetUploaded(AssetResponseDto newAsset) {
state = [...state, newAsset];
final int i = state.indexWhere(
(a) =>
a.isRemote ||
(a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId),
);
if (i == -1 || state[i].deviceAssetId != newAsset.deviceAssetId) {
state = [...state, Asset.remote(newAsset)];
} else {
// order is important to keep all local-only assets at the beginning!
state = [
...state.slice(0, i),
...state.slice(i + 1),
Asset.remote(newAsset),
];
// TODO here is a place to unify local/remote assets by replacing the
// local-only asset in the state with a local&remote asset
}
_cacheState();
}
deleteAssets(Set<AssetResponseDto> deleteAssets) async {
deleteAssets(Set<Asset> deleteAssets) async {
_deleteInProgress = true;
try {
final localDeleted = await _deleteLocalAssets(deleteAssets);
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
final Set<String> deleted = HashSet();
deleted.addAll(localDeleted);
deleted.addAll(remoteDeleted);
if (deleted.isNotEmpty) {
state = state.where((a) => !deleted.contains(a.id)).toList();
_cacheState();
}
} finally {
_deleteInProgress = false;
}
}
Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"];
var deleteIdList = <String>[];
final List<String> local = [];
// Delete asset from device
for (var asset in deleteAssets) {
// Delete asset on device if present
if (asset.deviceId == deviceId) {
for (final Asset asset in assetsToDelete) {
if (asset.isLocal) {
local.add(asset.id);
} else if (asset.deviceId == deviceId) {
// Delete asset on device if it is still present
var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
if (localAsset != null) {
deleteIdList.add(localAsset.id);
local.add(localAsset.id);
}
}
}
try {
await PhotoManager.editor.deleteWithIds(deleteIdList);
} catch (e) {
debugPrint("Delete asset from device failed: $e");
}
// Delete asset on server
List<DeleteAssetResponseDto>? deleteAssetResult =
await _assetService.deleteAssets(deleteAssets);
if (deleteAssetResult == null) {
return;
}
for (var asset in deleteAssetResult) {
if (asset.status == DeleteAssetStatus.SUCCESS) {
state =
state.where((immichAsset) => immichAsset.id != asset.id).toList();
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
} catch (e) {
debugPrint("Delete asset from device failed: $e");
}
}
return [];
}
_cacheState();
Future<Iterable<String>> _deleteRemoteAssets(
Set<Asset> assetsToDelete,
) async {
final Iterable<AssetResponseDto> remote =
assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!);
final List<DeleteAssetResponseDto> deleteAssetResult =
await _assetService.deleteAssets(remote) ?? [];
return deleteAssetResult
.where((a) => a.status == DeleteAssetStatus.SUCCESS)
.map((a) => a.id);
}
}
final assetProvider =
StateNotifierProvider<AssetNotifier, List<AssetResponseDto>>((ref) {
final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
});
final assetGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(assetProvider);
final assets = ref.watch(assetProvider).toList();
// `toList()` ist needed to make a copy as to NOT sort the original list/state
assets.sortByCompare<DateTime>(
(e) => DateTime.parse(e.createdAt),
(e) => e.createdAt,
(a, b) => b.compareTo(a),
);
return assets.groupListsBy(
(element) => DateFormat('y-MM-dd')
.format(DateTime.parse(element.createdAt).toLocal()),
(element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
);
});
final assetGroupByMonthYearProvider = StateProvider((ref) {
var assets = ref.watch(assetProvider);
// TODO: remove `where` once temporary workaround is no longer needed (to only
// allow remote assets to be added to album). Keep `toList()` as to NOT sort
// the original list/state
final assets = ref.watch(assetProvider).where((e) => e.isRemote).toList();
assets.sortByCompare<DateTime>(
(e) => DateTime.parse(e.createdAt),
(e) => e.createdAt,
(a, b) => b.compareTo(a),
);
return assets.groupListsBy(
(element) => DateFormat('MMMM, y')
.format(DateTime.parse(element.createdAt).toLocal()),
(element) => DateFormat('MMMM, y').format(element.createdAt.toLocal()),
);
});

View File

@@ -2,11 +2,11 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path/path.dart' as p;
import 'api.service.dart';
final shareServiceProvider =
@@ -17,26 +17,28 @@ class ShareService {
ShareService(this._apiService);
Future<void> shareAsset(AssetResponseDto asset) async {
Future<void> shareAsset(Asset asset) async {
await shareAssets([asset]);
}
Future<void> shareAssets(List<AssetResponseDto> assets) async {
Future<void> shareAssets(List<Asset> assets) async {
final downloadedFilePaths = assets.map((asset) async {
final res = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.deviceAssetId,
asset.deviceId,
isThumb: false,
isWeb: false,
);
final fileName = p.basename(asset.originalPath);
final tempDir = await getTemporaryDirectory();
final tempFile = await File('${tempDir.path}/$fileName').create();
tempFile.writeAsBytesSync(res.bodyBytes);
return tempFile.path;
if (asset.isRemote) {
final tempDir = await getTemporaryDirectory();
final fileName = basename(asset.remote!.originalPath);
final tempFile = await File('${tempDir.path}/$fileName').create();
final res = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.remote!.deviceAssetId,
asset.remote!.deviceId,
isThumb: false,
isWeb: false,
);
tempFile.writeAsBytesSync(res.bodyBytes);
return tempFile.path;
} else {
File? f = await asset.local!.file;
return f!.path;
}
});
Share.shareFiles(

View File

@@ -0,0 +1,96 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:photo_manager/photo_manager.dart';
/// Renders an Asset using local data if available, else remote data
class ImmichImage extends StatelessWidget {
const ImmichImage(
this.asset, {
required this.width,
required this.height,
this.useGrayBoxPlaceholder = false,
super.key,
});
final Asset asset;
final bool useGrayBoxPlaceholder;
final double width;
final double height;
@override
Widget build(BuildContext context) {
if (asset.isLocal) {
return Image(
image: AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
),
width: width,
height: height,
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return (useGrayBoxPlaceholder
? const SizedBox.square(
dimension: 250,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
),
)
: Transform.scale(
scale: 0.2,
child: const CircularProgressIndicator(),
));
},
errorBuilder: (context, error, stackTrace) {
debugPrint("Error getting thumb for assetId=${asset.id}: $error");
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
);
}
final String token = Hive.box(userInfoBox).get(accessTokenKey);
final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!);
return CachedNetworkImage(
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer $token"},
cacheKey: 'thumbnail-image-${asset.id}',
width: width,
height: height,
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
// maxHeightDiskCache = null allows to simply store the webp thumbnail
// from the server and use it for all rendered thumbnail sizes
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) {
if (useGrayBoxPlaceholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(
value: downloadProgress.progress,
),
);
},
errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
);
}
}