From 0d4d59c7e728a64a3d88b7c906d5e0601dcf4bb8 Mon Sep 17 00:00:00 2001
From: Peter Ombodi
Date: Fri, 17 Apr 2026 12:43:24 +0300
Subject: [PATCH] refactor(mobile): extract MediaStore utils and resolve view
intents via merged assets
---
.../immich/BackgroundServicePlugin.kt | 27 +--
.../alextran/immich/media/MediaStoreUtils.kt | 103 ++++++++
.../immich/viewintent/ViewIntentPlugin.kt | 81 +------
.../entities/merged_asset.drift | 98 ++++++++
.../entities/merged_asset.drift.dart | 46 ++++
.../repositories/timeline.repository.dart | 20 ++
mobile/lib/main.dart | 2 +-
.../view_intent_handler.provider.dart | 120 +--------
..._service.dart => view_intent.service.dart} | 0
.../view_intent_asset_resolver.service.dart | 228 ++++++++++++++++++
.../services/view_intent_service_test.dart | 2 +-
11 files changed, 520 insertions(+), 207 deletions(-)
create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/media/MediaStoreUtils.kt
rename mobile/lib/services/{view_intent_service.dart => view_intent.service.dart} (100%)
create mode 100644 mobile/lib/services/view_intent_asset_resolver.service.dart
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 {}