refactor(mobile): extract MediaStore utils and resolve view intents via merged assets

This commit is contained in:
Peter Ombodi
2026-04-17 12:43:24 +03:00
parent b3b0b0f576
commit 0d4d59c7e7
11 changed files with 520 additions and 207 deletions
@@ -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
@@ -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>,
): 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
}
}
}
@@ -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? {
@@ -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;
@@ -100,6 +100,52 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
}
i0.Selectable<int> mergedAssetIndexByLocalId({
required List<String> 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<String>(localAssetId),
for (var $ in userIds) i0.Variable<String>($),
],
readsFrom: {
localAssetEntity,
remoteAssetEntity,
stackEntity,
localAlbumAssetEntity,
localAlbumEntity,
},
).map((i0.QueryRow row) => row.read<int>('idx'));
}
i0.Selectable<int> mergedAssetIndexByChecksum({
required List<String> 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<String>(checksum),
for (var $ in userIds) i0.Variable<String>($),
],
readsFrom: {
remoteAssetEntity,
stackEntity,
localAssetEntity,
localAlbumAssetEntity,
localAlbumEntity,
},
).map((i0.QueryRow row) => row.read<int>('idx'));
}
i4.$RemoteAssetEntityTable get remoteAssetEntity => i1.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity');
@@ -672,6 +672,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
}
Future<int?> getMainTimelineIndexByChecksum(List<String> userIds, String checksum) async {
if (userIds.isEmpty) {
return null;
}
final result = await _db.mergedAssetDrift
.mergedAssetIndexByChecksum(userIds: userIds, checksum: checksum)
.getSingleOrNull();
return result;
}
Future<int?> getMainTimelineIndexByLocalId(List<String> userIds, String localAssetId) async {
if (userIds.isEmpty) {
return null;
}
final result = await _db.mergedAssetDrift
.mergedAssetIndexByLocalId(userIds: userIds, localAssetId: localAssetId)
.getSingleOrNull();
return result;
}
}
List<Bucket> _generateBuckets(int count) {
+1 -1
View File
@@ -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';
@@ -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<ViewIntentHandler>(
(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<bool> _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<String?> _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,
);
}
}
@@ -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<ViewIntentAssetResolver>(
(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<ViewIntentResolvedAsset> 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<ViewIntentResolvedAsset?> _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<ViewIntentResolvedAsset?> _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<String> _resolveMainTimelineUsers() {
final timelineUsers = _ref.read(timelineUsersProvider).valueOrNull;
final currentUserId = _ref.read(currentUserProvider)?.id;
return timelineUsers ?? (currentUserId != null ? [currentUserId] : const <String>[]);
}
Future<ViewIntentResolvedAsset?> _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<String?> _computeChecksum(ViewIntentPayload attachment) async {
final localAssetId = attachment.localAssetId;
if (localAssetId != null) {
return _computeChecksumForLocalAsset(localAssetId);
}
return _computeChecksumForPath(attachment.path);
}
Future<String?> _computeChecksumForLocalAsset(String localAssetId) async {
try {
final hashResults = await _nativeSyncApi.hashAssets([localAssetId]);
if (hashResults.isEmpty) {
return null;
}
return hashResults.first.hash;
} catch (_) {
return null;
}
}
Future<String?> _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,
);
}
}
@@ -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 {}