Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): render assets on device by default - with stream #10513

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Future<void> initApp() async {

PlatformDispatcher.instance.onError = (error, stack) {
log.severe('PlatformDispatcher - Catch all', error, stack);
debugPrint("PlatformDispatcher - Catch all: $error $stack");
return true;
};

Expand Down
73 changes: 2 additions & 71 deletions mobile/lib/services/album.service.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
Expand All @@ -28,7 +23,6 @@ final albumServiceProvider = Provider(
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
ref.watch(backupServiceProvider),
),
);

Expand All @@ -37,7 +31,6 @@ class AlbumService {
final UserService _userService;
final SyncService _syncService;
final Isar _db;
final BackupService _backupService;
final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false);
Expand All @@ -47,7 +40,6 @@ class AlbumService {
this._userService,
this._syncService,
this._db,
this._backupService,
);

/// Checks all selected device albums for changes of albums and their assets
Expand All @@ -62,60 +54,14 @@ class AlbumService {
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final List<String> excludedIds =
await _backupService.excludedAlbumsQuery().idProperty().findAll();
final List<String> selectedIds =
await _backupService.selectedAlbumsQuery().idProperty().findAll();
if (selectedIds.isEmpty) {
final numLocal = await _db.albums.where().localIdIsNotNull().count();
if (numLocal > 0) {
_syncService.removeAllLocalAlbumsAndAssets();
}
return false;
}
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) {
if (Platform.isIOS) {
// iOS and Android device album working principle differ significantly
// on iOS, an asset can be in multiple albums
// on Android, an asset can only be in exactly one album (folder!) at the same time
// thus, on Android, excluding an album can be done by ignoring that album
// however, on iOS, it it necessary to load the assets from all excluded
// albums and check every asset from any selected album against the set
// of excluded assets
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
_log.info("Found ${excludedAssets.length} assets to exclude");
}
// remove all excluded albums
onDevice.removeWhere((e) => excludedIds.contains(e.id));
_log.info(
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
);
}
final hasAll = selectedIds
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
.whereNotNull()
.any((a) => a.isAll);
if (hasAll) {
if (Platform.isAndroid) {
// remove the virtual "Recent" album and keep and individual albums
// on Android, the virtual "Recent" `lastModified` value is always null
onDevice.removeWhere((e) => e.isAll);
_log.info("'Recents' is selected, keeping all individual albums");
}
} else {
// keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
_log.info("'Recents' is not selected, keeping only selected albums");
}
changes =
await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);

changes = await _syncService.syncLocalAlbumAssetsToDb(onDevice);
_log.info("Syncing completed. Changes: $changes");
} finally {
_localCompleter.complete(changes);
Expand All @@ -124,21 +70,6 @@ class AlbumService {
return changes;
}

Future<Set<String>> _loadExcludedAssetIds(
List<AssetPathEntity> albums,
List<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
for (AssetPathEntity a in albums) {
if (excludedAlbumIds.contains(a.id)) {
final List<AssetEntity> assets =
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
result.addAll(assets.map((e) => e.id));
}
}
return result;
}

/// Checks remote albums (owned if `isShared` is false) for changes,
/// updates the local database and returns `true` if there were any changes
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
Expand Down
55 changes: 24 additions & 31 deletions mobile/lib/services/hash.service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,23 @@ class HashService {
final _log = Logger('HashService');

/// Returns all assets that were successfully hashed
Future<List<Asset>> getHashedAssets(
Stream<List<Asset>> getHashedAssets(
AssetPathEntity album, {
int start = 0,
int end = 0x7fffffffffffffff,
Set<String>? excludedAssets,
}) async {
}) async* {
final entities = await album.getAssetListRange(start: start, end: end);
final filtered = excludedAssets == null
? entities
: entities.where((e) => !excludedAssets.contains(e.id)).toList();
return _hashAssets(filtered);
// yield _hashAssets(entities);
await for (final assets in _hashAssets(entities)) {
yield assets;
}
}

/// Converts a list of [AssetEntity]s to [Asset]s including only those
/// that were successfully hashed. Hashes are looked up in a DB table
/// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing
/// entries are newly hashed and added to the DB table.
Future<List<Asset>> _hashAssets(List<AssetEntity> assetEntities) async {
Stream<List<Asset>> _hashAssets(List<AssetEntity> assetEntities) async* {
const int batchFileCount = 128;
const int batchDataSize = 1024 * 1024 * 1024; // 1GB

Expand All @@ -46,7 +45,7 @@ class HashService {
.toList();
final List<DeviceAsset?> hashes = await _lookupHashes(ids);
final List<DeviceAsset> toAdd = [];
final List<String> toHash = [];
final List<Map<String, AssetEntity>> toHash = [];

int bytes = 0;

Expand All @@ -70,23 +69,26 @@ class HashService {
continue;
}
bytes += await file.length();
toHash.add(file.path);
toHash.add({file.path: assetEntities[i]});
final deviceAsset = Platform.isAndroid
? AndroidDeviceAsset(id: ids[i] as int, hash: const [])
: IOSDeviceAsset(id: ids[i] as String, hash: const []);
toAdd.add(deviceAsset);
hashes[i] = deviceAsset;
if (toHash.length == batchFileCount || bytes >= batchDataSize) {
await _processBatch(toHash, toAdd);
await for (final batch in _processBatch(toHash, toAdd)) {
yield batch;
}
toAdd.clear();
toHash.clear();
bytes = 0;
}
}
if (toHash.isNotEmpty) {
await _processBatch(toHash, toAdd);
await for (final batch in _processBatch(toHash, toAdd)) {
yield batch;
}
}
return _mapAllHashedAssets(assetEntities, hashes);
}

/// Lookup hashes of assets by their local ID
Expand All @@ -97,29 +99,34 @@ class HashService {

/// Processes a batch of files and saves any successfully hashed
/// values to the DB table.
Future<void> _processBatch(
final List<String> toHash,
Stream<List<Asset>> _processBatch(
final List<Map<String, AssetEntity>> toHash,
final List<DeviceAsset> toAdd,
) async {
final hashes = await _hashFiles(toHash);
) async* {
final List<Asset> validLocalAssets = [];
final hashes = await _hashFiles(toHash.map((e) => e.keys.first).toList());
bool anyNull = false;
for (int j = 0; j < hashes.length; j++) {
if (hashes[j]?.length == 20) {
toAdd[j].hash = hashes[j]!;
validLocalAssets.add(Asset.local(toHash[j].values.first, hashes[j]!));
} else {
_log.warning("Failed to hash file ${toHash[j]}, skipping");
anyNull = true;
}
}

final validHashes = anyNull
? toAdd.where((e) => e.hash.length == 20).toList(growable: false)
: toAdd;

await _db.writeTxn(
() => Platform.isAndroid
? _db.androidDeviceAssets.putAll(validHashes.cast())
: _db.iOSDeviceAssets.putAll(validHashes.cast()),
);
_log.fine("Hashed ${validHashes.length}/${toHash.length} assets");
yield validLocalAssets;
}

/// Hashes the given files and returns a list of the same length
Expand All @@ -132,20 +139,6 @@ class HashService {
}
return hashes;
}

/// Converts [AssetEntity]s that were successfully hashed to [Asset]s
List<Asset> _mapAllHashedAssets(
List<AssetEntity> assets,
List<DeviceAsset?> hashes,
) {
final List<Asset> result = [];
for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) {
result.add(Asset.local(assets[i], hashes[i]!.hash));
}
}
return result;
}
}

final hashServiceProvider = Provider(
Expand Down
Loading
Loading