mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 23:52:32 -04:00
refactor(mobile): extract MediaStore utils and resolve view intents via merged assets
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-78
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user