diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt index f62f25558d..d3d8bdd35d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt @@ -8,11 +8,12 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle -import android.provider.MediaStore -import android.provider.Settings -import android.util.Log -import androidx.annotation.RequiresApi -import io.flutter.embedding.engine.plugins.FlutterPlugin +import android.provider.MediaStore +import android.provider.Settings +import android.util.Log +import androidx.annotation.RequiresApi +import app.alextran.immich.media.MediaStoreUtils +import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger @@ -254,7 +255,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, return } - val uri = ContentUris.withAppendedId(contentUriForType(type), id) + val uri = ContentUris.withAppendedId(MediaStoreUtils.contentUriForAssetType(type), id) try { Log.i(TAG, "restoreFromTrashById: uri=$uri (type=$type,id=$id)") @@ -305,7 +306,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor -> if (cursor.moveToFirst()) { val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) - return ContentUris.withAppendedId(contentUriForType(type), id) + return ContentUris.withAppendedId(MediaStoreUtils.contentUriForAssetType(type), id) } } return null @@ -373,17 +374,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Log.i(TAG, "restoreUris: count=${uris.size}, first=${uris.first()}") toggleTrash(uris, false, result) } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun contentUriForType(type: Int): Uri = - when (type) { - // same order as AssetType from dart - 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI - 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) - } -} +} private const val TAG = "BackgroundServicePlugin" private const val BUFFER_SIZE = 2 * 1024 * 1024 diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/media/MediaStoreUtils.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/media/MediaStoreUtils.kt new file mode 100644 index 0000000000..3af520bb77 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/media/MediaStoreUtils.kt @@ -0,0 +1,103 @@ +package app.alextran.immich.media + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.provider.OpenableColumns + +object MediaStoreUtils { + private fun externalFilesUri(): Uri = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + } else { + MediaStore.Files.getContentUri("external") + } + + fun contentUriForMimeType(mimeType: String): Uri = + when { + mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + mimeType.startsWith("audio/") -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + else -> externalFilesUri() + } + + fun contentUriForAssetType(type: Int): Uri = + when (type) { + // same order as AssetType from dart + 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + else -> externalFilesUri() + } + + fun resolveLocalIdByRelativePath(context: Context, path: String, mimeType: String): String? { + val fileName = path.substringAfterLast('/', missingDelimiterValue = path) + val parent = path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" } + if (fileName.isBlank()) return null + + val (selection, args) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?" to arrayOf(fileName, parent) + } else { + "${MediaStore.MediaColumns.DISPLAY_NAME}=?" to arrayOf(fileName) + } + + return queryLatestId( + context = context, + tableUri = contentUriForMimeType(mimeType), + selection = selection, + selectionArgs = args, + ) + } + + fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? { + val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + val (displayName, size) = + try { + context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) return null + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null + val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L + if (name.isNullOrBlank() || bytes < 0) return null + name to bytes + } ?: return null + } catch (_: Exception) { + return null + } + + return queryLatestId( + context = context, + tableUri = contentUriForMimeType(mimeType), + selection = "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?", + selectionArgs = arrayOf(displayName, size.toString()), + ) + } + + private fun queryLatestId( + context: Context, + tableUri: Uri, + selection: String, + selectionArgs: Array, + ): String? { + return try { + context.contentResolver + .query( + tableUri, + arrayOf(MediaStore.MediaColumns._ID), + selection, + selectionArgs, + "${MediaStore.MediaColumns.DATE_MODIFIED} DESC", + )?.use { cursor -> + if (!cursor.moveToFirst()) return null + val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + if (idIndex < 0) return null + cursor.getLong(idIndex).toString() + } + } catch (_: Exception) { + null + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt index 5407e782b2..1ad1d41fa3 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt @@ -5,10 +5,8 @@ import android.content.ContentUris import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.provider.DocumentsContract -import android.provider.MediaStore -import android.provider.OpenableColumns +import app.alextran.immich.media.MediaStoreUtils import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -125,7 +123,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL if (parsed.all(Char::isDigit)) { return parsed } - val fromRelativePath = resolveLocalIdByRelativePath(context, parsed, mimeType) + val fromRelativePath = MediaStoreUtils.resolveLocalIdByRelativePath(context, parsed, mimeType) if (fromRelativePath != null) { return fromRelativePath } @@ -149,80 +147,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL return segment } - return resolveLocalIdByNameAndSize(context, uri, mimeType) - } - - private fun resolveLocalIdByRelativePath(context: Context, path: String, mimeType: String): String? { - val fileName = path.substringAfterLast('/', missingDelimiterValue = path) - val parent = path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" } - if (fileName.isBlank()) return null - - val tableUri = when { - mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI - else -> MediaStore.Files.getContentUri("external") - } - - val projection = arrayOf(MediaStore.MediaColumns._ID) - val (selection, args) = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?" to arrayOf(fileName, parent) - } else { - "${MediaStore.MediaColumns.DISPLAY_NAME}=?" to arrayOf(fileName) - } - - return try { - context.contentResolver - .query(tableUri, projection, selection, args, "${MediaStore.MediaColumns.DATE_MODIFIED} DESC") - ?.use { cursor -> - if (!cursor.moveToFirst()) return null - val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) - if (idIndex < 0) return null - cursor.getLong(idIndex).toString() - } - } catch (_: Exception) { - null - } - } - - private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? { - val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) - val (displayName, size) = - try { - context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor -> - if (!cursor.moveToFirst()) return null - val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) - val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null - val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L - if (name.isNullOrBlank() || bytes < 0) return null - name to bytes - } ?: return null - } catch (_: Exception) { - return null - } - - val tableUri = when { - mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI - else -> MediaStore.Files.getContentUri("external") - } - val projection = arrayOf(MediaStore.MediaColumns._ID) - val selection = "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?" - val args = arrayOf(displayName, size.toString()) - - return try { - context.contentResolver - .query(tableUri, projection, selection, args, "${MediaStore.MediaColumns.DATE_MODIFIED} DESC") - ?.use { cursor -> - if (!cursor.moveToFirst()) return null - val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) - if (idIndex < 0) return null - cursor.getLong(idIndex).toString() - } - } catch (_: Exception) { - null - } + return MediaStoreUtils.resolveLocalIdByNameAndSize(context, uri, mimeType) } private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? { diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index 73276d1756..ce3372e54b 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -137,3 +137,101 @@ FROM ) GROUP BY bucket_date ORDER BY bucket_date DESC; + +mergedAssetIndexByLocalId: +SELECT + idx +FROM ( + SELECT + local_id, + ROW_NUMBER() OVER (ORDER BY created_at DESC) - 1 as idx + FROM ( + SELECT + (SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id, + rae.created_at as created_at + FROM + remote_asset_entity rae + LEFT JOIN + stack_entity se ON rae.stack_id = se.id + WHERE + rae.deleted_at IS NULL + AND rae.visibility = 0 -- timeline visibility + AND rae.owner_id IN :user_ids + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) + + UNION ALL + + SELECT + lae.id as local_id, + lae.created_at as created_at + FROM + local_asset_entity lae + WHERE NOT EXISTS ( + SELECT 1 FROM remote_asset_entity rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN :user_ids + ) + AND EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id AND la.backup_selection = 0 -- selected + ) + AND NOT EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded + ) + ) +) +WHERE local_id = :local_asset_id +LIMIT 1; + +mergedAssetIndexByChecksum: +SELECT + idx +FROM ( + SELECT + checksum, + ROW_NUMBER() OVER (ORDER BY created_at DESC) - 1 as idx + FROM ( + SELECT + rae.checksum as checksum, + rae.created_at as created_at + FROM + remote_asset_entity rae + LEFT JOIN + stack_entity se ON rae.stack_id = se.id + WHERE + rae.deleted_at IS NULL + AND rae.visibility = 0 -- timeline visibility + AND rae.owner_id IN :user_ids + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) + + UNION ALL + + SELECT + lae.checksum as checksum, + lae.created_at as created_at + FROM + local_asset_entity lae + WHERE NOT EXISTS ( + SELECT 1 FROM remote_asset_entity rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN :user_ids + ) + AND EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id AND la.backup_selection = 0 -- selected + ) + AND NOT EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded + ) + ) +) +WHERE checksum = :checksum +LIMIT 1; diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index c6004eb10d..2e15e0fe60 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift.dart +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -100,6 +100,52 @@ class MergedAssetDrift extends i1.ModularAccessor { ); } + i0.Selectable mergedAssetIndexByLocalId({ + required List userIds, + String? localAssetId, + }) { + var $arrayStartIndex = 2; + final expandeduserIds = $expandVar($arrayStartIndex, userIds.length); + $arrayStartIndex += userIds.length; + return customSelect( + 'SELECT idx FROM (SELECT local_id, ROW_NUMBER()OVER (ORDER BY created_at DESC RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) - 1 AS idx FROM (SELECT (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.created_at AS created_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.id AS local_id, lae.created_at AS created_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2))) WHERE local_id = ?1 LIMIT 1', + variables: [ + i0.Variable(localAssetId), + for (var $ in userIds) i0.Variable($), + ], + readsFrom: { + localAssetEntity, + remoteAssetEntity, + stackEntity, + localAlbumAssetEntity, + localAlbumEntity, + }, + ).map((i0.QueryRow row) => row.read('idx')); + } + + i0.Selectable mergedAssetIndexByChecksum({ + required List userIds, + String? checksum, + }) { + var $arrayStartIndex = 2; + final expandeduserIds = $expandVar($arrayStartIndex, userIds.length); + $arrayStartIndex += userIds.length; + return customSelect( + 'SELECT idx FROM (SELECT checksum, ROW_NUMBER()OVER (ORDER BY created_at DESC RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) - 1 AS idx FROM (SELECT rae.checksum AS checksum, rae.created_at AS created_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.checksum AS checksum, lae.created_at AS created_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2))) WHERE checksum = ?1 LIMIT 1', + variables: [ + i0.Variable(checksum), + for (var $ in userIds) i0.Variable($), + ], + readsFrom: { + remoteAssetEntity, + stackEntity, + localAssetEntity, + localAlbumAssetEntity, + localAlbumEntity, + }, + ).map((i0.QueryRow row) => row.read('idx')); + } + i4.$RemoteAssetEntityTable get remoteAssetEntity => i1.ReadDatabaseContainer( attachedDatabase, ).resultSet('remote_asset_entity'); diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 74af6dc3f0..282de00055 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -672,6 +672,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } } + + Future getMainTimelineIndexByChecksum(List userIds, String checksum) async { + if (userIds.isEmpty) { + return null; + } + final result = await _db.mergedAssetDrift + .mergedAssetIndexByChecksum(userIds: userIds, checksum: checksum) + .getSingleOrNull(); + return result; + } + + Future getMainTimelineIndexByLocalId(List userIds, String localAssetId) async { + if (userIds.isEmpty) { + return null; + } + final result = await _db.mergedAssetDrift + .mergedAssetIndexByLocalId(userIds: userIds, localAssetId: localAssetId) + .getSingleOrNull(); + return result; + } } List _generateBuckets(int count) { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c75a41d0a1..7dfacc1fa4 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -37,7 +37,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/deep_link.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; -import 'package:immich_mobile/services/view_intent_service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart index ad826fb775..f621342a28 100644 --- a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart +++ b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart @@ -1,50 +1,38 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; -import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart'; -import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/view_intent_service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; +import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; import 'package:logging/logging.dart'; final viewIntentHandlerProvider = Provider( (ref) => ViewIntentHandler( ref, ref.read(viewIntentServiceProvider), + ref.read(viewIntentAssetResolverProvider), ref.watch(appRouterProvider), - ref.read(localAssetRepository), - ref.read(nativeSyncApiProvider), - ref.read(timelineFactoryProvider), ), ); class ViewIntentHandler { final Ref _ref; final ViewIntentService _viewIntentService; + final ViewIntentAssetResolver _viewIntentAssetResolver; final AppRouter _router; - final DriftLocalAssetRepository _localAssetRepository; - final NativeSyncApi _nativeSyncApi; - final TimelineFactory _timelineFactory; static final Logger _logger = Logger('ViewIntentHandler'); const ViewIntentHandler( this._ref, this._viewIntentService, + this._viewIntentAssetResolver, this._router, - this._localAssetRepository, - this._nativeSyncApi, - this._timelineFactory, ); void init() { @@ -67,75 +55,16 @@ class ViewIntentHandler { return; } - final localAssetId = attachment.localAssetId; - _logger.fine('localAssetId: $localAssetId'); - if (localAssetId != null) { - final localAsset = await _localAssetRepository.getById(localAssetId); - if (localAsset != null) { - var checksum = localAsset.checksum; - if (checksum == null) { - checksum = await _computeChecksum(localAssetId); - if (checksum != null) { - await _localAssetRepository.updateHashes({localAssetId: checksum}); - } - } - final timelineMatch = await _openFromMainTimeline(localAssetId, checksum: checksum); - _logger.fine('localAsset: $localAsset, checksum: $checksum, timelineMatch: $timelineMatch'); - if (timelineMatch) { - return; - } - _openAssetViewer(localAsset, _timelineFactory.fromAssets([localAsset], TimelineOrigin.deepLink), 0); - return; - } - } - final checksum = localAssetId != null ? await _computeChecksum(localAssetId) : null; - final fallbackAsset = _toViewIntentAsset(attachment, checksum); - _logger.fine('openAssetViewer for fallbackAsset'); + final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment); + _logger.fine('resolved view intent asset: ${resolvedAsset.asset}'); _openAssetViewer( - fallbackAsset, - _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), - 0, - viewIntentFilePath: attachment.path, + resolvedAsset.asset, + resolvedAsset.timelineService, + resolvedAsset.initialIndex, + viewIntentFilePath: resolvedAsset.viewIntentFilePath, ); } - Future _openFromMainTimeline(String localAssetId, {String? checksum}) async { - final timelineService = _ref.read(timelineServiceProvider); - if (timelineService.totalAssets == 0) { - try { - await timelineService.watchBuckets().first.timeout(const Duration(seconds: 2)); - } catch (_) { - // Ignore and fallback. - } - } - - final totalAssets = timelineService.totalAssets; - if (totalAssets == 0) { - return false; - } - - final batchSize = kTimelineAssetLoadBatchSize; - for (var offset = 0; offset < totalAssets; offset += batchSize) { - final count = (offset + batchSize > totalAssets) ? totalAssets - offset : batchSize; - final assets = await timelineService.loadAssets(offset, count); - final indexInBatch = assets.indexWhere((asset) { - if (asset.localId == localAssetId) { - return true; - } - if (checksum != null && asset.checksum == checksum) { - return true; - } - return false; - }); - if (indexInBatch >= 0) { - final asset = assets[indexInBatch]; - _openAssetViewer(asset, timelineService, offset + indexInBatch); - return true; - } - } - return false; - } - void _openAssetViewer( BaseAsset asset, TimelineService timelineService, @@ -158,31 +87,4 @@ class ViewIntentHandler { _router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService)); } - - Future _computeChecksum(String localAssetId) async { - try { - final hashResults = await _nativeSyncApi.hashAssets([localAssetId]); - if (hashResults.isEmpty) { - return null; - } - return hashResults.first.hash; - } catch (_) { - return null; - } - } - - LocalAsset _toViewIntentAsset(ViewIntentPayload attachment, String? checksum) { - final now = DateTime.now(); - return LocalAsset( - // todo Temp solution, need to provide FileBackedAsset extends BaseAsset for cover this case in right way - id: attachment.localAssetId ?? '-${attachment.path.hashCode.abs()}', - name: attachment.fileName, - checksum: checksum, - type: attachment.isVideo ? AssetType.video : AssetType.image, - createdAt: now, - updatedAt: now, - isEdited: false, - playbackStyle: attachment.playbackStyle, - ); - } } diff --git a/mobile/lib/services/view_intent_service.dart b/mobile/lib/services/view_intent.service.dart similarity index 100% rename from mobile/lib/services/view_intent_service.dart rename to mobile/lib/services/view_intent.service.dart diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart new file mode 100644 index 0000000000..f3e2e269e7 --- /dev/null +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/background.service.dart'; +import 'package:logging/logging.dart'; + +class ViewIntentResolvedAsset { + final BaseAsset asset; + final TimelineService timelineService; + final int initialIndex; + final String? viewIntentFilePath; + + const ViewIntentResolvedAsset({ + required this.asset, + required this.timelineService, + required this.initialIndex, + this.viewIntentFilePath, + }); +} + +final viewIntentAssetResolverProvider = Provider( + (ref) => ViewIntentAssetResolver( + ref, + ref.read(localAssetRepository), + ref.read(nativeSyncApiProvider), + ref.read(backgroundServiceProvider), + ref.read(timelineFactoryProvider), + ), +); + +class ViewIntentAssetResolver { + final Ref _ref; + final DriftLocalAssetRepository _localAssetRepository; + final NativeSyncApi _nativeSyncApi; + final BackgroundService _backgroundService; + final TimelineFactory _timelineFactory; + static final Logger _logger = Logger('ViewIntentAssetResolver'); + + const ViewIntentAssetResolver( + this._ref, + this._localAssetRepository, + this._nativeSyncApi, + this._backgroundService, + this._timelineFactory, + ); + + Future resolve(ViewIntentPayload attachment) async { + final localAssetId = attachment.localAssetId; + if (localAssetId != null) { + var localAsset = await _localAssetRepository.getById(localAssetId); + if (localAsset != null) { + // Try the direct local-id match first when the intent resolves to a + // real MediaStore asset. + final mainTimelineAsset = await _resolveMainTimelineAssetByLocalId(localAssetId); + if (mainTimelineAsset != null) { + _logger.fine('presenting main timeline asset via localAssetId: ${mainTimelineAsset.asset}'); + return mainTimelineAsset; + } + + var checksum = localAsset.checksum; + if (checksum == null) { + checksum = await _computeChecksumForLocalAsset(localAssetId); + if (checksum != null) { + localAsset = localAsset.copyWith(checksum: checksum); + } + } + + // If local id does not match the merged timeline, retry by checksum + // because the same asset may already be represented there as a merged + // local/remote asset. + if (checksum != null) { + final checksumTimelineAsset = await _resolveMainTimelineAssetByChecksum(checksum); + if (checksumTimelineAsset != null) { + _logger.fine('presenting main timeline asset via checksum fallback: ${checksumTimelineAsset.asset}'); + return checksumTimelineAsset; + } + } + + _logger.fine('presenting deep-link local asset: $localAsset'); + return ViewIntentResolvedAsset( + asset: localAsset, + timelineService: _timelineFactory.fromAssets([localAsset], TimelineOrigin.deepLink), + initialIndex: 0, + ); + } + } + + final checksum = await _computeChecksum(attachment); + if (checksum != null) { + // Some ACTION_VIEW sources do not provide a local MediaStore id, so + // checksum is the only way to match the incoming file to an existing + // merged asset. + final mainTimelineAsset = await _resolveMainTimelineAssetByChecksum(checksum); + if (mainTimelineAsset != null) { + _logger.fine('presenting main timeline asset via checksum-only match: ${mainTimelineAsset.asset}'); + return mainTimelineAsset; + } + } + + final fallbackAsset = _toViewIntentAsset(attachment, checksum); + _logger.fine('presenting transient fallback asset: $fallbackAsset'); + return ViewIntentResolvedAsset( + asset: fallbackAsset, + timelineService: _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), + initialIndex: 0, + viewIntentFilePath: attachment.path, + ); + } + + Future _resolveMainTimelineAssetByLocalId(String localAssetId) async { + final effectiveTimelineUsers = _resolveMainTimelineUsers(); + if (effectiveTimelineUsers.isEmpty) { + return null; + } + + final index = await _ref + .read(timelineRepositoryProvider) + .getMainTimelineIndexByLocalId(effectiveTimelineUsers, localAssetId); + if (index == null) { + return null; + } + + return _resolveMainTimelineAssetAt(index); + } + + Future _resolveMainTimelineAssetByChecksum(String checksum) async { + final effectiveTimelineUsers = _resolveMainTimelineUsers(); + if (effectiveTimelineUsers.isEmpty) { + return null; + } + + final index = await _ref + .read(timelineRepositoryProvider) + .getMainTimelineIndexByChecksum(effectiveTimelineUsers, checksum); + if (index == null) { + return null; + } + + return _resolveMainTimelineAssetAt(index); + } + + List _resolveMainTimelineUsers() { + final timelineUsers = _ref.read(timelineUsersProvider).valueOrNull; + final currentUserId = _ref.read(currentUserProvider)?.id; + return timelineUsers ?? (currentUserId != null ? [currentUserId] : const []); + } + + Future _resolveMainTimelineAssetAt(int index) async { + final timelineService = _ref.read(timelineServiceProvider); + if (timelineService.totalAssets == 0) { + try { + await timelineService.watchBuckets().first.timeout(const Duration(seconds: 2)); + } catch (_) { + return null; + } + } + + if (index >= timelineService.totalAssets) { + return null; + } + + final asset = await timelineService.getAssetAsync(index); + if (asset == null) { + return null; + } + + return ViewIntentResolvedAsset(asset: asset, timelineService: timelineService, initialIndex: index); + } + + Future _computeChecksum(ViewIntentPayload attachment) async { + final localAssetId = attachment.localAssetId; + if (localAssetId != null) { + return _computeChecksumForLocalAsset(localAssetId); + } + return _computeChecksumForPath(attachment.path); + } + + Future _computeChecksumForLocalAsset(String localAssetId) async { + try { + final hashResults = await _nativeSyncApi.hashAssets([localAssetId]); + if (hashResults.isEmpty) { + return null; + } + return hashResults.first.hash; + } catch (_) { + return null; + } + } + + Future _computeChecksumForPath(String path) async { + try { + final hashes = await _backgroundService.digestFiles([path]); + final hash = hashes == null || hashes.isEmpty ? null : hashes.first; + if (hash == null || hash.length != 20) { + return null; + } + return base64.encode(hash); + } catch (_) { + return null; + } + } + + LocalAsset _toViewIntentAsset(ViewIntentPayload attachment, String? checksum) { + final now = DateTime.now(); + return LocalAsset( + // todo Temp solution, need to provide FileBackedAsset extends BaseAsset for cover this case in right way + id: attachment.localAssetId ?? '-${attachment.path.hashCode.abs()}', + name: attachment.fileName, + checksum: checksum, + type: attachment.isVideo ? AssetType.video : AssetType.image, + createdAt: now, + updatedAt: now, + isEdited: false, + playbackStyle: attachment.playbackStyle, + ); + } +} diff --git a/mobile/test/services/view_intent_service_test.dart b/mobile/test/services/view_intent_service_test.dart index 1a2a039000..e3cdaef153 100644 --- a/mobile/test/services/view_intent_service_test.dart +++ b/mobile/test/services/view_intent_service_test.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; -import 'package:immich_mobile/services/view_intent_service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:mocktail/mocktail.dart'; class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}