mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 23:52:32 -04:00
cleanup
This commit is contained in:
@@ -94,7 +94,6 @@
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:mimeType="image/*" />
|
||||
<data android:scheme="file" android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Allow Immich to act as a video viewer -->
|
||||
@@ -102,7 +101,6 @@
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:mimeType="video/*" />
|
||||
<data android:scheme="file" android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- immich:// URL scheme handling -->
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
|
||||
private const val TAG = "MediaStoreUtils"
|
||||
|
||||
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 resolveMimeType(context: Context, uri: Uri, fallbackMimeType: String? = null): String? {
|
||||
return context.contentResolver.getType(uri)
|
||||
?: fallbackMimeType
|
||||
?: resolveMimeTypeFromDisplayName(context, uri)
|
||||
?: resolveMimeTypeFromPath(uri.path)
|
||||
?: resolveMimeTypeFromPath(uri.toString())
|
||||
}
|
||||
|
||||
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 resolveMimeTypeFromDisplayName(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (displayNameIndex < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
resolveMimeTypeFromPath(cursor.getString(displayNameIndex))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to resolve MIME type from display name: $uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveMimeTypeFromPath(path: String?): String? {
|
||||
if (path.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val extension = path.substringAfterLast('.', missingDelimiterValue = "").substringBefore('?').substringBefore('#')
|
||||
if (extension.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,6 @@ interface NativeSyncApi {
|
||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||
fun hashFiles(paths: List<String>, callback: (Result<List<HashResult>>) -> Unit)
|
||||
fun cancelHashing()
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
@@ -718,26 +717,6 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val pathsArg = args[0] as List<String>
|
||||
api.hashFiles(pathsArg) { result: Result<List<HashResult>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
|
||||
@@ -30,8 +30,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -422,44 +420,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
}
|
||||
}
|
||||
|
||||
fun hashFiles(
|
||||
paths: List<String>,
|
||||
callback: (Result<List<HashResult>>) -> Unit
|
||||
) {
|
||||
if (paths.isEmpty()) {
|
||||
completeWhenActive(callback, Result.success(emptyList()))
|
||||
return
|
||||
}
|
||||
|
||||
hashTask?.cancel()
|
||||
hashTask = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val results = paths.map { path ->
|
||||
async {
|
||||
hashSemaphore.withPermit {
|
||||
ensureActive()
|
||||
hashFile(path)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
completeWhenActive(callback, Result.success(results))
|
||||
} catch (e: CancellationException) {
|
||||
completeWhenActive(
|
||||
callback, Result.failure(
|
||||
FlutterError(
|
||||
HASHING_CANCELLED_CODE,
|
||||
"Hashing operation was cancelled",
|
||||
null
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
completeWhenActive(callback, Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun hashAsset(assetId: String): HashResult {
|
||||
return try {
|
||||
val assetUri = ContentUris.withAppendedId(
|
||||
@@ -467,10 +427,17 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
assetId.toLong()
|
||||
)
|
||||
|
||||
val hashString = ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
|
||||
hashInputStream(inputStream)
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
|
||||
var bytesRead: Int
|
||||
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
digest.update(buffer, 0, bytesRead)
|
||||
}
|
||||
} ?: return HashResult(assetId, "Cannot open input stream for asset", null)
|
||||
|
||||
val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
|
||||
HashResult(assetId, null, hashString)
|
||||
} catch (e: SecurityException) {
|
||||
HashResult(assetId, "Permission denied accessing asset: ${e.message}", null)
|
||||
@@ -479,35 +446,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun hashFile(path: String): HashResult {
|
||||
return try {
|
||||
val file = File(path)
|
||||
if (!file.exists()) {
|
||||
return HashResult(path, "File does not exist", null)
|
||||
}
|
||||
|
||||
val hashString = FileInputStream(file).use { inputStream ->
|
||||
hashInputStream(inputStream)
|
||||
}
|
||||
HashResult(path, null, hashString)
|
||||
} catch (e: SecurityException) {
|
||||
HashResult(path, "Permission denied accessing file: ${e.message}", null)
|
||||
} catch (e: Exception) {
|
||||
HashResult(path, "Failed to hash file: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun hashInputStream(inputStream: InputStream): String {
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
var bytesRead: Int
|
||||
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
digest.update(buffer, 0, bytesRead)
|
||||
}
|
||||
return Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun cancelHashing() {
|
||||
hashTask?.cancel()
|
||||
hashTask = null
|
||||
|
||||
+60
-69
@@ -5,10 +5,12 @@ 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 android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
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
|
||||
@@ -83,7 +85,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
|
||||
|
||||
ioScope.launch {
|
||||
try {
|
||||
val mimeType = MediaStoreUtils.resolveMimeType(context, uri, intent.type)
|
||||
val mimeType = context.contentResolver.getType(uri) ?: intent.type
|
||||
if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
|
||||
callback(Result.success(null))
|
||||
return@launch
|
||||
@@ -121,52 +123,22 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
|
||||
}
|
||||
|
||||
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
|
||||
if (uri.scheme != "content") {
|
||||
return null
|
||||
}
|
||||
|
||||
val fromDocumentUri = tryExtractDocumentLocalAssetId(context, uri, mimeType)
|
||||
if (fromDocumentUri != null) {
|
||||
return fromDocumentUri
|
||||
}
|
||||
|
||||
val fromContentUri = tryParseContentUriId(uri)
|
||||
if (fromContentUri != null) {
|
||||
return fromContentUri
|
||||
}
|
||||
|
||||
val fromPathSegment = tryParseLastPathSegmentId(uri)
|
||||
if (fromPathSegment != null) {
|
||||
return fromPathSegment
|
||||
}
|
||||
|
||||
return MediaStoreUtils.resolveLocalIdByNameAndSize(context, uri, mimeType)
|
||||
return tryExtractDocumentLocalAssetId(context, uri)
|
||||
?: tryParseContentUriId(uri)
|
||||
?: tryParseLastPathSegmentId(uri)
|
||||
?: resolveLocalIdByNameAndSize(context, uri, mimeType)
|
||||
}
|
||||
|
||||
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
|
||||
try {
|
||||
if (!DocumentsContract.isDocumentUri(context, uri)) {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
if (!DocumentsContract.isDocumentUri(context, uri)) return null
|
||||
val docId = DocumentsContract.getDocumentId(uri)
|
||||
if (docId.startsWith("raw:")) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (docId.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (docId.isBlank() || docId.startsWith("raw:")) return null
|
||||
val parsed = docId.substringAfter(':', docId)
|
||||
if (parsed.all(Char::isDigit)) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return MediaStoreUtils.resolveLocalIdByRelativePath(context, parsed, mimeType)
|
||||
if (parsed.isNotEmpty() && parsed.all(Char::isDigit)) parsed else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
|
||||
return null
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,33 +159,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
|
||||
|
||||
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
|
||||
return try {
|
||||
val normalizedMimeType = mimeType.substringBefore(';').lowercase()
|
||||
val mimeTypeExtension = MimeTypeMap
|
||||
.getSingleton()
|
||||
.getExtensionFromMimeType(normalizedMimeType)
|
||||
?.let { ".$it" }
|
||||
|
||||
val extension = when {
|
||||
normalizedMimeType.startsWith("image/") -> {
|
||||
when {
|
||||
normalizedMimeType.contains("jpeg") || normalizedMimeType.contains("jpg") -> ".jpg"
|
||||
normalizedMimeType.contains("png") -> ".png"
|
||||
normalizedMimeType.contains("gif") -> ".gif"
|
||||
normalizedMimeType.contains("webp") -> ".webp"
|
||||
else -> mimeTypeExtension ?: ".jpg"
|
||||
}
|
||||
}
|
||||
normalizedMimeType.startsWith("video/") -> {
|
||||
when {
|
||||
normalizedMimeType.contains("mp4") -> ".mp4"
|
||||
normalizedMimeType.contains("webm") -> ".webm"
|
||||
normalizedMimeType.contains("3gp") -> ".3gp"
|
||||
else -> mimeTypeExtension ?: ".mp4"
|
||||
}
|
||||
}
|
||||
else -> mimeTypeExtension ?: ".tmp"
|
||||
}
|
||||
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
|
||||
val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
FileOutputStream(tempFile).use { outputStream ->
|
||||
@@ -225,4 +171,49 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
|
||||
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 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
} else {
|
||||
MediaStore.Files.getContentUri("external")
|
||||
}
|
||||
}
|
||||
return try {
|
||||
context.contentResolver
|
||||
.query(
|
||||
tableUri,
|
||||
arrayOf(MediaStore.MediaColumns._ID),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
|
||||
arrayOf(displayName, size.toString()),
|
||||
"${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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-20
@@ -535,7 +535,6 @@ protocol NativeSyncApi {
|
||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func hashFiles(paths: [String], completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func cancelHashing() throws
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
@@ -695,25 +694,6 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
hashAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let hashFilesChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
hashFilesChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let pathsArg = args[0] as! [String]
|
||||
api.hashFiles(paths: pathsArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hashFilesChannel.setMessageHandler(nil)
|
||||
}
|
||||
let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
cancelHashingChannel.setMessageHandler { _, reply in
|
||||
|
||||
@@ -319,13 +319,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
func hashFiles(paths: [String], completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
||||
let results = paths.map { path in
|
||||
HashResult(assetId: path, error: "Not implemented on iOS", hash: nil)
|
||||
}
|
||||
completeWhenActive(for: completion, with: .success(results))
|
||||
}
|
||||
|
||||
func cancelHashing() {
|
||||
hashTask?.cancel()
|
||||
hashTask = nil
|
||||
|
||||
@@ -139,150 +139,3 @@ 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;
|
||||
|
||||
mergedAssetIndexByRemoteId:
|
||||
SELECT
|
||||
idx
|
||||
FROM (
|
||||
SELECT
|
||||
remote_id,
|
||||
ROW_NUMBER() OVER (ORDER BY created_at DESC) - 1 as idx
|
||||
FROM (
|
||||
SELECT
|
||||
rae.id as remote_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
|
||||
NULL as remote_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 remote_id = :remote_id
|
||||
LIMIT 1;
|
||||
|
||||
@@ -101,75 +101,6 @@ 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'));
|
||||
}
|
||||
|
||||
i0.Selectable<int> mergedAssetIndexByRemoteId({
|
||||
required List<String> userIds,
|
||||
String? remoteId,
|
||||
}) {
|
||||
var $arrayStartIndex = 2;
|
||||
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
||||
$arrayStartIndex += userIds.length;
|
||||
return customSelect(
|
||||
'SELECT idx FROM (SELECT remote_id, ROW_NUMBER()OVER (ORDER BY created_at DESC RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) - 1 AS idx FROM (SELECT rae.id AS remote_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 NULL AS remote_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 remote_id = ?1 LIMIT 1',
|
||||
variables: [
|
||||
i0.Variable<String>(remoteId),
|
||||
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');
|
||||
|
||||
@@ -679,35 +679,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Future<int?> getMainTimelineIndexByRemoteId(List<String> userIds, String remoteAssetId) async {
|
||||
if (userIds.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final result = await _db.mergedAssetDrift
|
||||
.mergedAssetIndexByRemoteId(userIds: userIds, remoteId: remoteAssetId)
|
||||
.getSingleOrNull();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
List<Bucket> _generateBuckets(int count) {
|
||||
|
||||
+170
-145
@@ -9,14 +9,22 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
Object? _extractReplyValueOrThrow(
|
||||
List<Object?>? replyList,
|
||||
String channelName, {
|
||||
required bool isNullValid,
|
||||
}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
throw PlatformException(
|
||||
code: replyList[0]! as String,
|
||||
message: replyList[1] as String?,
|
||||
details: replyList[2],
|
||||
);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
@@ -37,7 +45,9 @@ bool _deepEquals(Object? a, Object? b) {
|
||||
return a == b;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
return a.length == b.length &&
|
||||
a.indexed
|
||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) {
|
||||
@@ -86,7 +96,15 @@ int _deepHash(Object? value) {
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
enum PlatformAssetPlaybackStyle {
|
||||
unknown,
|
||||
image,
|
||||
video,
|
||||
imageAnimated,
|
||||
livePhoto,
|
||||
videoLooping,
|
||||
}
|
||||
|
||||
class PlatformAsset {
|
||||
PlatformAsset({
|
||||
@@ -154,8 +172,7 @@ class PlatformAsset {
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static PlatformAsset decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -186,20 +203,7 @@ class PlatformAsset {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(id, other.id) &&
|
||||
_deepEquals(name, other.name) &&
|
||||
_deepEquals(type, other.type) &&
|
||||
_deepEquals(createdAt, other.createdAt) &&
|
||||
_deepEquals(updatedAt, other.updatedAt) &&
|
||||
_deepEquals(width, other.width) &&
|
||||
_deepEquals(height, other.height) &&
|
||||
_deepEquals(durationMs, other.durationMs) &&
|
||||
_deepEquals(orientation, other.orientation) &&
|
||||
_deepEquals(isFavorite, other.isFavorite) &&
|
||||
_deepEquals(adjustmentTime, other.adjustmentTime) &&
|
||||
_deepEquals(latitude, other.latitude) &&
|
||||
_deepEquals(longitude, other.longitude) &&
|
||||
_deepEquals(playbackStyle, other.playbackStyle);
|
||||
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(type, other.type) && _deepEquals(createdAt, other.createdAt) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(width, other.width) && _deepEquals(height, other.height) && _deepEquals(durationMs, other.durationMs) && _deepEquals(orientation, other.orientation) && _deepEquals(isFavorite, other.isFavorite) && _deepEquals(adjustmentTime, other.adjustmentTime) && _deepEquals(latitude, other.latitude) && _deepEquals(longitude, other.longitude) && _deepEquals(playbackStyle, other.playbackStyle);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -227,12 +231,17 @@ class PlatformAlbum {
|
||||
int assetCount;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[id, name, updatedAt, isCloud, assetCount];
|
||||
return <Object?>[
|
||||
id,
|
||||
name,
|
||||
updatedAt,
|
||||
isCloud,
|
||||
assetCount,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static PlatformAlbum decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -254,11 +263,7 @@ class PlatformAlbum {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(id, other.id) &&
|
||||
_deepEquals(name, other.name) &&
|
||||
_deepEquals(updatedAt, other.updatedAt) &&
|
||||
_deepEquals(isCloud, other.isCloud) &&
|
||||
_deepEquals(assetCount, other.assetCount);
|
||||
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(isCloud, other.isCloud) && _deepEquals(assetCount, other.assetCount);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -267,7 +272,12 @@ class PlatformAlbum {
|
||||
}
|
||||
|
||||
class SyncDelta {
|
||||
SyncDelta({required this.hasChanges, required this.updates, required this.deletes, required this.assetAlbums});
|
||||
SyncDelta({
|
||||
required this.hasChanges,
|
||||
required this.updates,
|
||||
required this.deletes,
|
||||
required this.assetAlbums,
|
||||
});
|
||||
|
||||
bool hasChanges;
|
||||
|
||||
@@ -278,12 +288,16 @@ class SyncDelta {
|
||||
Map<String, List<String>> assetAlbums;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[hasChanges, updates, deletes, assetAlbums];
|
||||
return <Object?>[
|
||||
hasChanges,
|
||||
updates,
|
||||
deletes,
|
||||
assetAlbums,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static SyncDelta decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -304,10 +318,7 @@ class SyncDelta {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(hasChanges, other.hasChanges) &&
|
||||
_deepEquals(updates, other.updates) &&
|
||||
_deepEquals(deletes, other.deletes) &&
|
||||
_deepEquals(assetAlbums, other.assetAlbums);
|
||||
return _deepEquals(hasChanges, other.hasChanges) && _deepEquals(updates, other.updates) && _deepEquals(deletes, other.deletes) && _deepEquals(assetAlbums, other.assetAlbums);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -316,7 +327,11 @@ class SyncDelta {
|
||||
}
|
||||
|
||||
class HashResult {
|
||||
HashResult({required this.assetId, this.error, this.hash});
|
||||
HashResult({
|
||||
required this.assetId,
|
||||
this.error,
|
||||
this.hash,
|
||||
});
|
||||
|
||||
String assetId;
|
||||
|
||||
@@ -325,16 +340,23 @@ class HashResult {
|
||||
String? hash;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[assetId, error, hash];
|
||||
return <Object?>[
|
||||
assetId,
|
||||
error,
|
||||
hash,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static HashResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?);
|
||||
return HashResult(
|
||||
assetId: result[0]! as String,
|
||||
error: result[1] as String?,
|
||||
hash: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -355,7 +377,11 @@ class HashResult {
|
||||
}
|
||||
|
||||
class CloudIdResult {
|
||||
CloudIdResult({required this.assetId, this.error, this.cloudId});
|
||||
CloudIdResult({
|
||||
required this.assetId,
|
||||
this.error,
|
||||
this.cloudId,
|
||||
});
|
||||
|
||||
String assetId;
|
||||
|
||||
@@ -364,16 +390,23 @@ class CloudIdResult {
|
||||
String? cloudId;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[assetId, error, cloudId];
|
||||
return <Object?>[
|
||||
assetId,
|
||||
error,
|
||||
cloudId,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static CloudIdResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?);
|
||||
return CloudIdResult(
|
||||
assetId: result[0]! as String,
|
||||
error: result[1] as String?,
|
||||
cloudId: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -385,9 +418,7 @@ class CloudIdResult {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(assetId, other.assetId) &&
|
||||
_deepEquals(error, other.error) &&
|
||||
_deepEquals(cloudId, other.cloudId);
|
||||
return _deepEquals(assetId, other.assetId) && _deepEquals(error, other.error) && _deepEquals(cloudId, other.cloudId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -395,6 +426,7 @@ class CloudIdResult {
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -402,22 +434,22 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
} else if (value is PlatformAsset) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is PlatformAlbum) {
|
||||
} else if (value is PlatformAlbum) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SyncDelta) {
|
||||
} else if (value is SyncDelta) {
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is HashResult) {
|
||||
} else if (value is HashResult) {
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
} else if (value is CloudIdResult) {
|
||||
buffer.putUint8(134);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
@@ -452,8 +484,8 @@ class NativeSyncApi {
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
@@ -461,8 +493,7 @@ class NativeSyncApi {
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<bool> shouldFullSync() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -472,16 +503,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<SyncDelta> getMediaChanges() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -491,16 +522,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as SyncDelta;
|
||||
}
|
||||
|
||||
Future<void> checkpointSync() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -509,12 +540,16 @@ class NativeSyncApi {
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Future<void> clearSyncCheckpoint() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -523,12 +558,16 @@ class NativeSyncApi {
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Future<List<String>> getAssetIdsForAlbum(String albumId) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -538,16 +577,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
|
||||
}
|
||||
|
||||
Future<List<PlatformAlbum>> getAlbums() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -557,16 +596,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAlbum>();
|
||||
}
|
||||
|
||||
Future<int> getAssetsCountSince(String albumId, int timestamp) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -576,16 +615,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as int;
|
||||
}
|
||||
|
||||
Future<List<PlatformAsset>> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -595,16 +634,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAsset>();
|
||||
}
|
||||
|
||||
Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -614,35 +653,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>();
|
||||
}
|
||||
|
||||
Future<List<HashResult>> hashFiles(List<String> paths) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[paths]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>();
|
||||
}
|
||||
|
||||
Future<void> cancelHashing() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -651,12 +671,16 @@ class NativeSyncApi {
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -666,16 +690,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -685,16 +709,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -704,10 +728,11 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,9 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/main_timeline_handoff.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -61,16 +59,12 @@ class UploadActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
var success = false;
|
||||
String? uploadedRemoteAssetId;
|
||||
if (!isTimeline && viewerIntentFilePath != null) {
|
||||
var hasError = false;
|
||||
await ref
|
||||
.read(foregroundUploadServiceProvider)
|
||||
.uploadShareIntent(
|
||||
[File(viewerIntentFilePath)],
|
||||
onSuccess: (_, remoteAssetId) {
|
||||
uploadedRemoteAssetId = remoteAssetId;
|
||||
},
|
||||
onError: (fileId, errorMessage) {
|
||||
hasError = true;
|
||||
},
|
||||
@@ -79,18 +73,12 @@ class UploadActionButton extends ConsumerWidget {
|
||||
} else {
|
||||
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||
success = result.success;
|
||||
uploadedRemoteAssetId = result.remoteAssetIds.isNotEmpty ? result.remoteAssetIds.first : null;
|
||||
}
|
||||
|
||||
if (!isTimeline && context.mounted && isUploadDialogOpen) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
if (!isTimeline && success) {
|
||||
final origin = ref.read(timelineServiceProvider).origin;
|
||||
unawaited(ref.read(mainTimelineHandoffProvider).startIfNeeded(origin, remoteAssetId: uploadedRemoteAssetId));
|
||||
}
|
||||
|
||||
if (context.mounted && !success && !wasUploadCancelled) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
|
||||
@@ -20,7 +20,6 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.prov
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/main_timeline_handoff.provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -79,7 +78,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
StreamSubscription? _reloadSubscription;
|
||||
KeepAliveLink? _stackChildrenKeepAlive;
|
||||
MainTimelineHandoffCoordinator? _mainTimelineHandoffCoordinator;
|
||||
bool _disposeStarted = false;
|
||||
|
||||
void _onTapNavigate(int direction) {
|
||||
@@ -99,9 +97,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (ref.read(timelineServiceProvider).origin == TimelineOrigin.deepLink) {
|
||||
_mainTimelineHandoffCoordinator = ref.read(mainTimelineHandoffProvider);
|
||||
}
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
|
||||
if (asset != null) {
|
||||
@@ -119,7 +114,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeStarted = true;
|
||||
_mainTimelineHandoffCoordinator?.cancel();
|
||||
_pageController.dispose();
|
||||
_preloader.dispose();
|
||||
_reloadSubscription?.cancel();
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
|
||||
typedef MainTimelineIndexLookup = Future<int?> Function(List<String> userIds, String value);
|
||||
typedef MainTimelineHandoff = Future<void> Function(List<String> userIds, int index, String? viewIntentFilePath);
|
||||
typedef UploadReadyWaiter = Future<void> Function(bool Function(dynamic data) predicate, Duration timeout);
|
||||
typedef TimelineReadyWaiter = Future<void> Function(TimelineService timelineService, Duration timeout);
|
||||
|
||||
final mainTimelineHandoffProvider = Provider.autoDispose<MainTimelineHandoffCoordinator>((ref) {
|
||||
final keepAliveLink = ref.keepAlive();
|
||||
|
||||
final coordinator = MainTimelineHandoffCoordinator(
|
||||
getCurrentAsset: () => ref.read(assetViewerProvider).currentAsset,
|
||||
getViewIntentFilePath: () => ref.read(viewIntentFilePathProvider),
|
||||
resolveMainTimelineUsers: () {
|
||||
final timelineUsers = ref.read(timelineUsersProvider).valueOrNull;
|
||||
final currentUserId = ref.read(currentUserProvider)?.id;
|
||||
return timelineUsers ?? (currentUserId != null ? [currentUserId] : const <String>[]);
|
||||
},
|
||||
findMainTimelineIndexByRemoteId: (userIds, remoteAssetId) {
|
||||
return ref.read(timelineRepositoryProvider).getMainTimelineIndexByRemoteId(userIds, remoteAssetId);
|
||||
},
|
||||
waitForUploadReadyEvent: (predicate, timeout) {
|
||||
return ref.read(websocketProvider.notifier).waitForEvent('AssetUploadReadyV1', predicate, timeout);
|
||||
},
|
||||
handoffToMainTimeline: (userIds, index, viewIntentFilePath) async {
|
||||
final timelineService = ref.read(timelineFactoryProvider).main(userIds);
|
||||
|
||||
try {
|
||||
final asset = await resolveAssetFromMainTimelineService(
|
||||
timelineService,
|
||||
index,
|
||||
waitForTimelineReady: waitForTimelineReady,
|
||||
);
|
||||
if (asset == null) {
|
||||
await timelineService.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(assetViewerProvider.notifier).setViewerTransitionInProgress(true);
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
try {
|
||||
await ref
|
||||
.read(appRouterProvider)
|
||||
.popAndPush(AssetViewerRoute(initialIndex: index, timelineService: timelineService));
|
||||
} finally {
|
||||
ref.read(assetViewerProvider.notifier).setViewerTransitionInProgress(false);
|
||||
}
|
||||
|
||||
if (viewIntentFilePath != null) {
|
||||
ref.read(viewIntentFilePathProvider.notifier).clearIfMatch(viewIntentFilePath);
|
||||
await ref.read(viewIntentServiceProvider).cleanupManagedTempFileIfCurrent(viewIntentFilePath);
|
||||
}
|
||||
} catch (_) {
|
||||
ref.read(assetViewerProvider.notifier).setViewerTransitionInProgress(false);
|
||||
await timelineService.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final lifetimeTimer = Timer(const Duration(seconds: 40), keepAliveLink.close);
|
||||
|
||||
ref
|
||||
..onDispose(lifetimeTimer.cancel)
|
||||
..onDispose(keepAliveLink.close)
|
||||
..onDispose(coordinator.dispose);
|
||||
return coordinator;
|
||||
});
|
||||
|
||||
Future<BaseAsset?> resolveAssetFromMainTimelineService(
|
||||
TimelineService timelineService,
|
||||
int index, {
|
||||
required TimelineReadyWaiter waitForTimelineReady,
|
||||
Duration timeout = const Duration(seconds: 3),
|
||||
Duration retryInterval = const Duration(milliseconds: 100),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
|
||||
if (!timelineService.isReady) {
|
||||
try {
|
||||
await waitForTimelineReady(timelineService, timeout);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
final totalAssets = timelineService.totalAssets;
|
||||
|
||||
if (index < totalAssets) {
|
||||
final asset = await timelineService.getAssetAsync(index);
|
||||
if (asset != null) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
await Future<void>.delayed(retryInterval);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class MainTimelineHandoffCoordinator {
|
||||
final BaseAsset? Function() _getCurrentAsset;
|
||||
final String? Function() _getViewIntentFilePath;
|
||||
final List<String> Function() _resolveMainTimelineUsers;
|
||||
final MainTimelineIndexLookup _findMainTimelineIndexByRemoteId;
|
||||
final UploadReadyWaiter _waitForUploadReadyEvent;
|
||||
final MainTimelineHandoff _handoffToMainTimeline;
|
||||
final Duration _uploadReadyTimeout;
|
||||
final Duration _mainTimelineAvailabilityTimeout;
|
||||
final Duration _mainTimelineRetryInterval;
|
||||
|
||||
bool _disposed = false;
|
||||
int _operationId = 0;
|
||||
|
||||
MainTimelineHandoffCoordinator({
|
||||
required BaseAsset? Function() getCurrentAsset,
|
||||
required String? Function() getViewIntentFilePath,
|
||||
required List<String> Function() resolveMainTimelineUsers,
|
||||
required MainTimelineIndexLookup findMainTimelineIndexByRemoteId,
|
||||
required UploadReadyWaiter waitForUploadReadyEvent,
|
||||
required MainTimelineHandoff handoffToMainTimeline,
|
||||
Duration uploadReadyTimeout = const Duration(seconds: 15),
|
||||
Duration mainTimelineAvailabilityTimeout = const Duration(seconds: 15),
|
||||
Duration mainTimelineRetryInterval = const Duration(milliseconds: 250),
|
||||
}) : _getCurrentAsset = getCurrentAsset,
|
||||
_getViewIntentFilePath = getViewIntentFilePath,
|
||||
_resolveMainTimelineUsers = resolveMainTimelineUsers,
|
||||
_findMainTimelineIndexByRemoteId = findMainTimelineIndexByRemoteId,
|
||||
_waitForUploadReadyEvent = waitForUploadReadyEvent,
|
||||
_handoffToMainTimeline = handoffToMainTimeline,
|
||||
_uploadReadyTimeout = uploadReadyTimeout,
|
||||
_mainTimelineAvailabilityTimeout = mainTimelineAvailabilityTimeout,
|
||||
_mainTimelineRetryInterval = mainTimelineRetryInterval;
|
||||
|
||||
Future<void> startIfNeeded(TimelineOrigin origin, {String? remoteAssetId}) async {
|
||||
if (_disposed || origin != TimelineOrigin.deepLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
final currentAsset = _getCurrentAsset();
|
||||
final viewIntentFilePath = _getViewIntentFilePath();
|
||||
if (currentAsset == null || remoteAssetId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final userIds = _resolveMainTimelineUsers();
|
||||
if (userIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final operationId = ++_operationId;
|
||||
|
||||
final match = _MainTimelineMatchCandidate(remoteAssetId: remoteAssetId);
|
||||
if (!_isOperationActive(operationId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final handoffContext = _MainTimelineHandoffContext(
|
||||
match: match,
|
||||
viewIntentFilePath: viewIntentFilePath,
|
||||
operationId: operationId,
|
||||
);
|
||||
|
||||
final didHandoffImmediately = await _tryHandoff(userIds, handoffContext);
|
||||
if (didHandoffImmediately || !_isOperationActive(handoffContext.operationId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _waitForUploadReadyEvent(
|
||||
(data) => _matchesUploadReadyEvent(data, handoffContext.match.remoteAssetId),
|
||||
_uploadReadyTimeout,
|
||||
);
|
||||
} on TimeoutException {
|
||||
return;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isOperationActive(handoffContext.operationId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _waitForMainTimelineAvailability(userIds, handoffContext);
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
_operationId++;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true;
|
||||
_operationId++;
|
||||
}
|
||||
|
||||
Future<bool> _waitForMainTimelineAvailability(
|
||||
List<String> userIds,
|
||||
_MainTimelineHandoffContext handoffContext,
|
||||
) async {
|
||||
final deadline = DateTime.now().add(_mainTimelineAvailabilityTimeout);
|
||||
|
||||
while (_isOperationActive(handoffContext.operationId) && DateTime.now().isBefore(deadline)) {
|
||||
final didHandoff = await _tryHandoff(userIds, handoffContext);
|
||||
if (didHandoff) {
|
||||
return true;
|
||||
}
|
||||
await Future<void>.delayed(_mainTimelineRetryInterval);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _tryHandoff(List<String> userIds, _MainTimelineHandoffContext handoffContext) async {
|
||||
if (!_isOperationActive(handoffContext.operationId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final index = await _findIndex(userIds, handoffContext.match);
|
||||
if (index == null || !_isOperationActive(handoffContext.operationId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _handoffToMainTimeline(userIds, index, handoffContext.viewIntentFilePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<int?> _findIndex(List<String> userIds, _MainTimelineMatchCandidate match) async {
|
||||
return _findMainTimelineIndexByRemoteId(userIds, match.remoteAssetId);
|
||||
}
|
||||
|
||||
bool _matchesUploadReadyEvent(dynamic data, String remoteAssetId) {
|
||||
final eventRemoteAssetId = switch (data) {
|
||||
{'asset': {'id': final String eventRemoteAssetId}} => eventRemoteAssetId,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return eventRemoteAssetId == remoteAssetId;
|
||||
}
|
||||
|
||||
bool _isOperationActive(int operationId) => !_disposed && _operationId == operationId;
|
||||
}
|
||||
|
||||
class _MainTimelineMatchCandidate {
|
||||
final String remoteAssetId;
|
||||
|
||||
const _MainTimelineMatchCandidate({required this.remoteAssetId});
|
||||
}
|
||||
|
||||
class _MainTimelineHandoffContext {
|
||||
final _MainTimelineMatchCandidate match;
|
||||
final String? viewIntentFilePath;
|
||||
final int operationId;
|
||||
|
||||
const _MainTimelineHandoffContext({required this.match, required this.viewIntentFilePath, required this.operationId});
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
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/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
|
||||
@@ -83,43 +81,19 @@ class AndroidViewIntentHandler implements ViewIntentHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
final resolvedAsset = await _viewIntentAssetResolver.resolve(
|
||||
attachment,
|
||||
timelineUsers: _resolveMainTimelineUsers(),
|
||||
mainTimelineService: _ref.read(timelineServiceProvider),
|
||||
);
|
||||
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
|
||||
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
|
||||
await _openAssetViewer(
|
||||
resolvedAsset.asset,
|
||||
resolvedAsset.timelineService,
|
||||
resolvedAsset.initialIndex,
|
||||
viewIntentFilePath: resolvedAsset.viewIntentFilePath,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _resolveMainTimelineUsers() {
|
||||
final timelineUsers = _ref.read(timelineUsersProvider).valueOrNull;
|
||||
final currentUserId = _ref.read(authProvider).userId;
|
||||
final effectiveTimelineUsers = timelineUsers != null && timelineUsers.isNotEmpty ? timelineUsers : [currentUserId];
|
||||
_logger.fine(
|
||||
'resolve main timeline users source, timelineUsers=$timelineUsers, currentUserId=$currentUserId, effective=$effectiveTimelineUsers',
|
||||
);
|
||||
return effectiveTimelineUsers;
|
||||
}
|
||||
|
||||
Future<void> _openAssetViewer(
|
||||
BaseAsset asset,
|
||||
TimelineService timelineService,
|
||||
int initialIndex, {
|
||||
String? viewIntentFilePath,
|
||||
}) async {
|
||||
Future<void> _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async {
|
||||
final notifier = _ref.read(assetViewerProvider.notifier);
|
||||
notifier.setViewerTransitionInProgress(true);
|
||||
try {
|
||||
_router.removeWhere((route) => route.name == AssetViewerRoute.name);
|
||||
|
||||
await _waitForNextFrame();
|
||||
|
||||
prepareAssetViewerState(notifier, asset);
|
||||
if (viewIntentFilePath != null) {
|
||||
_ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
|
||||
@@ -129,20 +103,16 @@ class AndroidViewIntentHandler implements ViewIntentHandler {
|
||||
unawaited(_viewIntentService.cleanupManagedTempFile());
|
||||
}
|
||||
|
||||
unawaited(_router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService)));
|
||||
await _waitForNextFrame();
|
||||
// Mirror the home-screen widget pattern: replace the route stack so
|
||||
// the viewer sits directly on top of the main timeline. Back-press
|
||||
// from the viewer lands the user on the timeline rather than on
|
||||
// whatever route happened to be current (e.g. splash, login).
|
||||
await _router.replaceAll([
|
||||
const TabShellRoute(),
|
||||
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
|
||||
]);
|
||||
} finally {
|
||||
notifier.setViewerTransitionInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForNextFrame() {
|
||||
final completer = Completer<void>();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,262 +1,78 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/infrastructure/repositories/timeline.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:logging/logging.dart';
|
||||
|
||||
class ViewIntentResolvedAsset {
|
||||
final BaseAsset asset;
|
||||
final TimelineService timelineService;
|
||||
final int initialIndex;
|
||||
|
||||
/// Path to the materialized temp file backing this asset, if any. Set only
|
||||
/// for the transient deep-link case (no DB-backed local asset). The upload
|
||||
/// flow reads this to know which file to upload.
|
||||
final String? viewIntentFilePath;
|
||||
|
||||
const ViewIntentResolvedAsset({
|
||||
required this.asset,
|
||||
required this.timelineService,
|
||||
required this.initialIndex,
|
||||
this.viewIntentFilePath,
|
||||
});
|
||||
const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath});
|
||||
}
|
||||
|
||||
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
|
||||
(ref) => ViewIntentAssetResolver(
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
nativeSyncApi: ref.read(nativeSyncApiProvider),
|
||||
timelineFactory: ref.read(timelineFactoryProvider),
|
||||
timelineRepository: ref.read(timelineRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
/// Resolves an incoming ACTION_VIEW intent into the data the asset viewer
|
||||
/// needs: a [BaseAsset] and a [TimelineService] containing it.
|
||||
///
|
||||
/// Always wraps the resolved asset in a 1-element [TimelineOrigin.deepLink]
|
||||
/// timeline — mirroring how the app's home-screen widgets open a single
|
||||
/// asset. We don't try to map the asset to its position in the user's main
|
||||
/// timeline because that would require ROW_NUMBER queries over the full
|
||||
/// merged timeline (slow at scale) and complex "wait until the main timeline
|
||||
/// service is ready at that index" coordination. Back-navigation from the
|
||||
/// viewer lands on the main timeline because the handler pushes the viewer
|
||||
/// on top of [TabShellRoute].
|
||||
class ViewIntentAssetResolver {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final TimelineFactory _timelineFactory;
|
||||
final DriftTimelineRepository _timelineRepository;
|
||||
static final Logger _logger = Logger('ViewIntentAssetResolver');
|
||||
|
||||
const ViewIntentAssetResolver({
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
required TimelineFactory timelineFactory,
|
||||
required DriftTimelineRepository timelineRepository,
|
||||
}) : _localAssetRepository = localAssetRepository,
|
||||
_nativeSyncApi = nativeSyncApi,
|
||||
_timelineFactory = timelineFactory,
|
||||
_timelineRepository = timelineRepository;
|
||||
_timelineFactory = timelineFactory;
|
||||
|
||||
Future<ViewIntentResolvedAsset> resolve(
|
||||
ViewIntentPayload attachment, {
|
||||
required List<String> timelineUsers,
|
||||
required TimelineService mainTimelineService,
|
||||
}) async {
|
||||
Future<ViewIntentResolvedAsset> resolve(ViewIntentPayload attachment) async {
|
||||
final localAssetId = attachment.localAssetId;
|
||||
final path = attachment.path;
|
||||
_logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}');
|
||||
|
||||
if (localAssetId == null && path == null) {
|
||||
throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.');
|
||||
}
|
||||
|
||||
if (localAssetId != null) {
|
||||
// Try the direct local-id match first when the intent resolves to a real
|
||||
// MediaStore asset.
|
||||
final mainTimelineAsset = await _resolveMainTimelineAssetByLocalId(
|
||||
localAssetId,
|
||||
timelineUsers,
|
||||
mainTimelineService,
|
||||
);
|
||||
if (mainTimelineAsset != null) {
|
||||
_logger.fine('presenting main timeline asset via localAssetId: ${mainTimelineAsset.asset}');
|
||||
return mainTimelineAsset;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the DB-backed local asset when we have one — it carries richer
|
||||
// metadata than the transient model we'd otherwise synthesise.
|
||||
final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null;
|
||||
_logger.fine('resolve local asset loaded: $localAsset');
|
||||
|
||||
final checksum = await _resolveChecksumForMatching(attachment, localAsset: localAsset);
|
||||
_logger.fine('resolve checksum for matching: $checksum');
|
||||
if (checksum != null) {
|
||||
final mainTimelineAsset = await _resolveMainTimelineAssetByChecksum(checksum, timelineUsers, mainTimelineService);
|
||||
if (mainTimelineAsset != null) {
|
||||
final lookupType = localAssetId != null ? 'checksum fallback' : 'checksum-only match';
|
||||
_logger.fine('presenting main timeline asset via $lookupType: ${mainTimelineAsset.asset}');
|
||||
return mainTimelineAsset;
|
||||
}
|
||||
}
|
||||
|
||||
final fallbackAsset = _toFallbackAsset(attachment, localAsset: localAsset, checksum: checksum);
|
||||
if (localAsset != null) {
|
||||
_logger.fine('resolve fallback to deep-link local asset: $fallbackAsset');
|
||||
} else {
|
||||
_logger.fine('resolve fallback to transient deep-link asset: $fallbackAsset');
|
||||
}
|
||||
final asset = localAsset ?? _toTransientAsset(attachment);
|
||||
|
||||
return ViewIntentResolvedAsset(
|
||||
asset: fallbackAsset,
|
||||
timelineService: _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink),
|
||||
initialIndex: 0,
|
||||
asset: asset,
|
||||
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
|
||||
// viewIntentFilePath is only meaningful for the transient case — the
|
||||
// DB-backed local asset carries its own path/URI for the upload flow.
|
||||
viewIntentFilePath: localAsset == null ? path : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAssetByLocalId(
|
||||
String localAssetId,
|
||||
List<String> timelineUsers,
|
||||
TimelineService mainTimelineService,
|
||||
) async {
|
||||
_logger.fine('resolve main timeline by localId start: $localAssetId');
|
||||
return _resolveMainTimelineAsset(
|
||||
() => _timelineRepository.getMainTimelineIndexByLocalId(timelineUsers, localAssetId),
|
||||
timelineUsers: timelineUsers,
|
||||
mainTimelineService: mainTimelineService,
|
||||
lookupLabel: 'localId=$localAssetId',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAssetByChecksum(
|
||||
String checksum,
|
||||
List<String> timelineUsers,
|
||||
TimelineService mainTimelineService,
|
||||
) async {
|
||||
// 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.
|
||||
_logger.fine('resolve main timeline by checksum start: $checksum');
|
||||
return _resolveMainTimelineAsset(
|
||||
() => _timelineRepository.getMainTimelineIndexByChecksum(timelineUsers, checksum),
|
||||
timelineUsers: timelineUsers,
|
||||
mainTimelineService: mainTimelineService,
|
||||
lookupLabel: 'checksum=$checksum',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAsset(
|
||||
Future<int?> Function() findIndex, {
|
||||
required List<String> timelineUsers,
|
||||
required TimelineService mainTimelineService,
|
||||
required String lookupLabel,
|
||||
}) async {
|
||||
_logger.fine('resolve main timeline users for $lookupLabel: $timelineUsers');
|
||||
if (timelineUsers.isEmpty) {
|
||||
_logger.fine('resolve main timeline aborted for $lookupLabel: timelineUsers is empty');
|
||||
return null;
|
||||
}
|
||||
|
||||
final index = await findIndex();
|
||||
_logger.fine('resolve main timeline index for $lookupLabel: $index');
|
||||
if (index == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _resolveMainTimelineAssetAt(index, mainTimelineService);
|
||||
}
|
||||
|
||||
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAssetAt(int index, TimelineService timelineService) async {
|
||||
_logger.fine(
|
||||
'resolve main timeline asset at index start: index=$index, origin=${timelineService.origin}, totalAssets=${timelineService.totalAssets}',
|
||||
);
|
||||
if (!timelineService.isReady) {
|
||||
try {
|
||||
await waitForTimelineReady(timelineService, const Duration(seconds: 3));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
BaseAsset? asset;
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 3));
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (index < timelineService.totalAssets) {
|
||||
asset = await timelineService.getAssetAsync(index);
|
||||
if (asset != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
_logger.fine(
|
||||
'resolve main timeline asset at index result: index=$index, totalAssetsAfterWait=${timelineService.totalAssets}, asset=$asset',
|
||||
);
|
||||
if (asset == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ViewIntentResolvedAsset(asset: asset, timelineService: timelineService, initialIndex: index);
|
||||
}
|
||||
|
||||
Future<String?> _resolveChecksumForMatching(ViewIntentPayload attachment, {LocalAsset? localAsset}) async {
|
||||
final localChecksum = localAsset?.checksum;
|
||||
if (localChecksum != null) {
|
||||
_logger.fine('resolve checksum from local db: $localChecksum');
|
||||
return localChecksum;
|
||||
}
|
||||
|
||||
final localAssetId = attachment.localAssetId;
|
||||
if (localAssetId != null) {
|
||||
_logger.fine('resolve checksum by hashing local asset: $localAssetId');
|
||||
return _computeChecksumForLocalAsset(localAssetId);
|
||||
}
|
||||
final path = attachment.path;
|
||||
if (path == null) {
|
||||
_logger.fine('resolve checksum aborted: path is null');
|
||||
return null;
|
||||
}
|
||||
_logger.fine('resolve checksum by hashing path: $path');
|
||||
return _computeChecksumForPath(path);
|
||||
}
|
||||
|
||||
Future<String?> _computeChecksumForLocalAsset(String localAssetId) async {
|
||||
try {
|
||||
final hashResults = await _nativeSyncApi.hashAssets([localAssetId]);
|
||||
if (hashResults.isEmpty) {
|
||||
_logger.fine('compute checksum for local asset returned empty: $localAssetId');
|
||||
return null;
|
||||
}
|
||||
_logger.fine('compute checksum for local asset succeeded: $localAssetId -> ${hashResults.first.hash}');
|
||||
return hashResults.first.hash;
|
||||
} catch (error, stackTrace) {
|
||||
_logger.warning('compute checksum for local asset failed: $localAssetId', error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _computeChecksumForPath(String path) async {
|
||||
try {
|
||||
final hashResults = await _nativeSyncApi.hashFiles([path]);
|
||||
if (hashResults.isEmpty) {
|
||||
_logger.fine('compute checksum for path returned empty: $path');
|
||||
return null;
|
||||
}
|
||||
_logger.fine('compute checksum for path succeeded: $path -> ${hashResults.first.hash}');
|
||||
return hashResults.first.hash;
|
||||
} catch (error, stackTrace) {
|
||||
_logger.warning('compute checksum for path failed: $path', error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
LocalAsset _toFallbackAsset(ViewIntentPayload attachment, {LocalAsset? localAsset, String? checksum}) {
|
||||
if (localAsset == null) {
|
||||
return _toViewIntentAsset(attachment, checksum);
|
||||
}
|
||||
|
||||
if (checksum == null || checksum == localAsset.checksum) {
|
||||
return localAsset;
|
||||
}
|
||||
|
||||
return localAsset.copyWith(checksum: checksum);
|
||||
}
|
||||
|
||||
LocalAsset _toViewIntentAsset(ViewIntentPayload attachment, String? checksum) {
|
||||
LocalAsset _toTransientAsset(ViewIntentPayload attachment) {
|
||||
final now = DateTime.now();
|
||||
return LocalAsset(
|
||||
// TODO(Ombodi): Introduce a file-backed BaseAsset for path-only view intents.
|
||||
@@ -264,7 +80,6 @@ class ViewIntentAssetResolver {
|
||||
// adapts an unmanaged file into the existing timeline/viewer pipeline.
|
||||
id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}',
|
||||
name: attachment.fileName,
|
||||
checksum: checksum,
|
||||
type: attachment.isVideo ? AssetType.video : AssetType.image,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -130,10 +130,6 @@ abstract class NativeSyncApi {
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<HashResult> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false});
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<HashResult> hashFiles(List<String> paths);
|
||||
|
||||
void cancelHashing();
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/main_timeline_handoff.provider.dart';
|
||||
|
||||
void main() {
|
||||
late BaseAsset? currentAsset;
|
||||
late String? viewIntentFilePath;
|
||||
late int? remoteIdIndex;
|
||||
late List<(List<String>, int, String?)> handoffs;
|
||||
late Completer<void> uploadReadyCompleter;
|
||||
late bool Function(dynamic)? uploadReadyPredicate;
|
||||
late MainTimelineHandoffCoordinator coordinator;
|
||||
|
||||
Future<void> flush() => Future<void>.delayed(Duration.zero);
|
||||
|
||||
setUp(() {
|
||||
currentAsset = _asset(checksum: 'checksum-1');
|
||||
viewIntentFilePath = null;
|
||||
remoteIdIndex = null;
|
||||
handoffs = [];
|
||||
uploadReadyCompleter = Completer<void>();
|
||||
uploadReadyPredicate = null;
|
||||
|
||||
coordinator = MainTimelineHandoffCoordinator(
|
||||
getCurrentAsset: () => currentAsset,
|
||||
getViewIntentFilePath: () => viewIntentFilePath,
|
||||
resolveMainTimelineUsers: () => ['user-1'],
|
||||
findMainTimelineIndexByRemoteId: (_, __) async => remoteIdIndex,
|
||||
waitForUploadReadyEvent: (predicate, _) {
|
||||
uploadReadyPredicate = predicate;
|
||||
return uploadReadyCompleter.future;
|
||||
},
|
||||
handoffToMainTimeline: (userIds, index, viewIntentFilePath) async {
|
||||
handoffs.add((userIds, index, viewIntentFilePath));
|
||||
},
|
||||
uploadReadyTimeout: const Duration(seconds: 1),
|
||||
mainTimelineAvailabilityTimeout: const Duration(seconds: 1),
|
||||
mainTimelineRetryInterval: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
addTearDown(coordinator.dispose);
|
||||
});
|
||||
|
||||
test('does not start outside deepLink origin', () async {
|
||||
remoteIdIndex = 5;
|
||||
|
||||
await coordinator.startIfNeeded(TimelineOrigin.main, remoteAssetId: 'remote-1');
|
||||
await flush();
|
||||
|
||||
expect(handoffs, isEmpty);
|
||||
expect(uploadReadyPredicate, isNull);
|
||||
});
|
||||
|
||||
test('hands off immediately when asset is already found in main timeline', () async {
|
||||
remoteIdIndex = 5;
|
||||
|
||||
await coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1');
|
||||
await flush();
|
||||
|
||||
expect(handoffs, hasLength(1));
|
||||
expect(handoffs.single.$1, ['user-1']);
|
||||
expect(handoffs.single.$2, 5);
|
||||
expect(handoffs.single.$3, isNull);
|
||||
expect(uploadReadyPredicate, isNull);
|
||||
});
|
||||
|
||||
test('waits for AssetUploadReadyV1 and then hands off when asset appears in main timeline', () async {
|
||||
final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1');
|
||||
await flush();
|
||||
|
||||
expect(handoffs, isEmpty);
|
||||
expect(uploadReadyPredicate, isNotNull);
|
||||
expect(
|
||||
uploadReadyPredicate!({
|
||||
'asset': {'id': 'remote-1'},
|
||||
}),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
remoteIdIndex = 7;
|
||||
uploadReadyCompleter.complete();
|
||||
await start;
|
||||
|
||||
expect(handoffs, hasLength(1));
|
||||
expect(handoffs.single.$1, ['user-1']);
|
||||
expect(handoffs.single.$2, 7);
|
||||
expect(handoffs.single.$3, isNull);
|
||||
});
|
||||
|
||||
test('does not start when remote asset id is missing', () async {
|
||||
currentAsset = _asset(localId: 'local-42', checksum: null);
|
||||
|
||||
await coordinator.startIfNeeded(TimelineOrigin.deepLink);
|
||||
await flush();
|
||||
|
||||
expect(handoffs, isEmpty);
|
||||
expect(uploadReadyPredicate, isNull);
|
||||
});
|
||||
|
||||
test('waits for AssetUploadReadyV1 when remote asset id is provided', () async {
|
||||
currentAsset = _remoteAsset(localId: null, checksum: null);
|
||||
|
||||
final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-9');
|
||||
await flush();
|
||||
|
||||
expect(handoffs, isEmpty);
|
||||
expect(uploadReadyPredicate, isNotNull);
|
||||
|
||||
await coordinator.dispose();
|
||||
uploadReadyCompleter.complete();
|
||||
await start;
|
||||
});
|
||||
|
||||
test('captures view intent file path at handoff start', () async {
|
||||
viewIntentFilePath = '/tmp/view_intent_old.jpg';
|
||||
final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1');
|
||||
await flush();
|
||||
|
||||
viewIntentFilePath = '/tmp/view_intent_new.jpg';
|
||||
remoteIdIndex = 4;
|
||||
uploadReadyCompleter.complete();
|
||||
await start;
|
||||
|
||||
expect(handoffs, hasLength(1));
|
||||
expect(handoffs.single.$3, '/tmp/view_intent_old.jpg');
|
||||
});
|
||||
|
||||
test('cancel prevents handoff after AssetUploadReadyV1 arrives later', () async {
|
||||
final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1');
|
||||
await flush();
|
||||
|
||||
coordinator.cancel();
|
||||
remoteIdIndex = 8;
|
||||
uploadReadyCompleter.complete();
|
||||
await start;
|
||||
|
||||
expect(handoffs, isEmpty);
|
||||
});
|
||||
|
||||
test('dispose prevents handoff after AssetUploadReadyV1 arrives later', () async {
|
||||
final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1');
|
||||
await flush();
|
||||
|
||||
await coordinator.dispose();
|
||||
remoteIdIndex = 9;
|
||||
uploadReadyCompleter.complete();
|
||||
await start;
|
||||
|
||||
expect(handoffs, isEmpty);
|
||||
});
|
||||
|
||||
test('resolveAssetFromMainTimelineService waits for timeline readiness', () async {
|
||||
final buckets = StreamController<List<Bucket>>();
|
||||
final asset = _remoteAsset(localId: 'local-1', checksum: 'checksum-1');
|
||||
final timelineService = TimelineService((
|
||||
assetSource: (index, count) async => [asset],
|
||||
bucketSource: () => buckets.stream,
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
|
||||
addTearDown(() async {
|
||||
await timelineService.dispose();
|
||||
await buckets.close();
|
||||
});
|
||||
|
||||
expect(timelineService.isReady, isFalse);
|
||||
|
||||
final resolveFuture = resolveAssetFromMainTimelineService(
|
||||
timelineService,
|
||||
0,
|
||||
waitForTimelineReady: (timelineService, _) async {
|
||||
for (var i = 0; i < 20 && !timelineService.isReady; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
},
|
||||
timeout: const Duration(seconds: 1),
|
||||
retryInterval: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
await flush();
|
||||
expect(timelineService.isReady, isFalse);
|
||||
|
||||
buckets.add(const [Bucket(assetCount: 1)]);
|
||||
|
||||
final resolved = await resolveFuture;
|
||||
await flush();
|
||||
expect(timelineService.isReady, isTrue);
|
||||
expect(resolved, same(asset));
|
||||
});
|
||||
}
|
||||
|
||||
LocalAsset _asset({String localId = 'local-1', String? checksum}) {
|
||||
return LocalAsset(
|
||||
id: localId,
|
||||
name: 'asset.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 4, 20),
|
||||
updatedAt: DateTime(2026, 4, 20),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
RemoteAsset _remoteAsset({String? localId, String? checksum}) {
|
||||
return RemoteAsset(
|
||||
id: 'remote-1',
|
||||
localId: localId,
|
||||
ownerId: 'user-1',
|
||||
name: 'asset.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 4, 20),
|
||||
updatedAt: DateTime(2026, 4, 20),
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
@@ -88,8 +88,6 @@ class TestAuthNotifier extends AuthNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
bool _pageRoutePredicate(PageRouteInfo<dynamic> route) => false;
|
||||
|
||||
final _handlerProvider = Provider<AndroidViewIntentHandler>((ref) => AndroidViewIntentHandler(ref));
|
||||
|
||||
void main() {
|
||||
@@ -107,10 +105,11 @@ void main() {
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakePageRouteInfo());
|
||||
registerFallbackValue(_pageRoutePredicate);
|
||||
registerFallbackValue(_localAsset(id: 'fallback'));
|
||||
registerFallbackValue(<String>[]);
|
||||
registerFallbackValue(<PageRouteInfo<dynamic>>[]);
|
||||
registerFallbackValue(FakeTimelineService());
|
||||
registerFallbackValue(
|
||||
ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
@@ -121,14 +120,15 @@ void main() {
|
||||
deepLinkAsset = _localAsset(id: 'local-1');
|
||||
deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink);
|
||||
|
||||
when(() => router.removeWhere(any())).thenReturn(false);
|
||||
when(() => router.push(any())).thenAnswer((_) async => null);
|
||||
when(() => router.replaceAll(any())).thenAnswer((_) async {});
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
viewIntentServiceProvider.overrideWithValue(viewIntentService),
|
||||
viewIntentAssetResolverProvider.overrideWithValue(resolver),
|
||||
appRouterProvider.overrideWithValue(router),
|
||||
// viewIntentMainTimelineReadyProvider reads both of these to compute
|
||||
// its ready state — without them wait() never resolves.
|
||||
timelineServiceProvider.overrideWithValue(deepLinkTimelineService),
|
||||
timelineUsersProvider.overrideWith((ref) => Stream.value(['user-1'])),
|
||||
authProvider.overrideWith((ref) {
|
||||
@@ -154,13 +154,7 @@ void main() {
|
||||
await handler.handle(payload);
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), payload);
|
||||
verifyNever(
|
||||
() => resolver.resolve(
|
||||
payload,
|
||||
timelineUsers: any(named: 'timelineUsers'),
|
||||
mainTimelineService: any(named: 'mainTimelineService'),
|
||||
),
|
||||
);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
testWidgets('flushDeferredViewIntent waits for main timeline readiness before flushing pending attachment', (
|
||||
@@ -170,27 +164,15 @@ void main() {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
||||
authNotifier.setAuthenticated(true);
|
||||
|
||||
when(
|
||||
() => resolver.resolve(
|
||||
payload,
|
||||
timelineUsers: any(named: 'timelineUsers'),
|
||||
mainTimelineService: any(named: 'mainTimelineService'),
|
||||
),
|
||||
).thenAnswer((_) async {
|
||||
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService, initialIndex: 0);
|
||||
when(() => resolver.resolve(payload)).thenAnswer((_) async {
|
||||
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
|
||||
});
|
||||
|
||||
unawaited(handler.flushDeferredViewIntent());
|
||||
await tester.pump();
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), payload);
|
||||
verifyNever(
|
||||
() => resolver.resolve(
|
||||
payload,
|
||||
timelineUsers: any(named: 'timelineUsers'),
|
||||
mainTimelineService: any(named: 'mainTimelineService'),
|
||||
),
|
||||
);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
|
||||
container.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce();
|
||||
await tester.pump();
|
||||
@@ -198,21 +180,13 @@ void main() {
|
||||
await tester.idle();
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), isNull);
|
||||
verify(
|
||||
() => resolver.resolve(payload, timelineUsers: ['user-1'], mainTimelineService: deepLinkTimelineService),
|
||||
).called(1);
|
||||
verify(() => resolver.resolve(payload)).called(1);
|
||||
});
|
||||
|
||||
test('flushDeferredViewIntent does nothing when there is no pending attachment', () async {
|
||||
await handler.flushDeferredViewIntent();
|
||||
|
||||
verifyNever(
|
||||
() => resolver.resolve(
|
||||
payload,
|
||||
timelineUsers: any(named: 'timelineUsers'),
|
||||
mainTimelineService: any(named: 'mainTimelineService'),
|
||||
),
|
||||
);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
test('onAppResumed cleans stale temp files when no attachment is present', () async {
|
||||
@@ -221,13 +195,7 @@ void main() {
|
||||
await handler.onAppResumed();
|
||||
|
||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 1);
|
||||
verifyNever(
|
||||
() => resolver.resolve(
|
||||
payload,
|
||||
timelineUsers: any(named: 'timelineUsers'),
|
||||
mainTimelineService: any(named: 'mainTimelineService'),
|
||||
),
|
||||
);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
test('onAppResumed does not clean stale temp files while pending attachment exists', () async {
|
||||
@@ -237,26 +205,13 @@ void main() {
|
||||
await handler.onAppResumed();
|
||||
|
||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 0);
|
||||
verifyNever(
|
||||
() => resolver.resolve(
|
||||
payload,
|
||||
timelineUsers: any(named: 'timelineUsers'),
|
||||
mainTimelineService: any(named: 'mainTimelineService'),
|
||||
),
|
||||
);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
|
||||
viewIntentService.consumedAttachment = payload;
|
||||
when(
|
||||
() => resolver.resolve(
|
||||
payload,
|
||||
timelineUsers: any(named: 'timelineUsers'),
|
||||
mainTimelineService: any(named: 'mainTimelineService'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async =>
|
||||
ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService, initialIndex: 0),
|
||||
when(() => resolver.resolve(payload)).thenAnswer(
|
||||
(_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService),
|
||||
);
|
||||
|
||||
unawaited(handler.onAppResumed());
|
||||
@@ -265,9 +220,15 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.idle();
|
||||
|
||||
verify(
|
||||
() => resolver.resolve(payload, timelineUsers: ['user-1'], mainTimelineService: deepLinkTimelineService),
|
||||
).called(1);
|
||||
verify(() => resolver.resolve(payload)).called(1);
|
||||
// Routes the user to [TabShell, AssetViewer] so back-press lands on the
|
||||
// main timeline — mirrors the home-screen widget navigation pattern.
|
||||
final captured = verify(() => router.replaceAll(captureAny())).captured;
|
||||
expect(captured, hasLength(1));
|
||||
final routes = captured.single as List<PageRouteInfo<dynamic>>;
|
||||
expect(routes, hasLength(2));
|
||||
expect(routes[0].routeName, TabShellRoute.name);
|
||||
expect(routes[1].routeName, AssetViewerRoute.name);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,39 +5,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.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/services/view_intent_asset_resolver.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
|
||||
class MockTimelineRepository extends Mock implements DriftTimelineRepository {}
|
||||
|
||||
class MockTimelineFactory extends Mock implements TimelineFactory {}
|
||||
|
||||
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
|
||||
void main() {
|
||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockNativeSyncApi nativeSyncApi;
|
||||
late MockTimelineRepository timelineRepository;
|
||||
late MockTimelineFactory timelineFactory;
|
||||
late TimelineService mainTimelineService;
|
||||
late List<TimelineService> createdTimelineServices;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() async {
|
||||
setUp(() {
|
||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||
nativeSyncApi = MockNativeSyncApi();
|
||||
timelineRepository = MockTimelineRepository();
|
||||
timelineFactory = MockTimelineFactory();
|
||||
createdTimelineServices = [];
|
||||
mainTimelineService = await _setMainTimelineService(const [], createdTimelineServices);
|
||||
|
||||
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
|
||||
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
|
||||
@@ -49,8 +36,6 @@ void main() {
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
localAssetRepository.overrideWith((ref) => mockLocalAssetRepository),
|
||||
nativeSyncApiProvider.overrideWith((ref) => nativeSyncApi),
|
||||
timelineRepositoryProvider.overrideWith((ref) => timelineRepository),
|
||||
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
|
||||
],
|
||||
);
|
||||
@@ -63,124 +48,53 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves main timeline asset by local id without hashing', () async {
|
||||
final localAsset = _localAsset(id: 'local-1');
|
||||
final mainAsset = _remoteAsset(id: 'remote-1', localId: 'local-1', checksum: 'checksum-1');
|
||||
mainTimelineService = await _setMainTimelineService([mainAsset], createdTimelineServices);
|
||||
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
|
||||
when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => 0);
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService);
|
||||
|
||||
expect(result.asset, same(mainAsset));
|
||||
expect(result.timelineService, same(mainTimelineService));
|
||||
expect(result.initialIndex, 0);
|
||||
expect(result.viewIntentFilePath, isNull);
|
||||
verifyNever(() => nativeSyncApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
verifyNever(() => timelineRepository.getMainTimelineIndexByChecksum(any(), any()));
|
||||
});
|
||||
|
||||
test('falls back to checksum from local db when local id is not in main timeline', () async {
|
||||
test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async {
|
||||
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1');
|
||||
final mainAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1');
|
||||
mainTimelineService = await _setMainTimelineService([mainAsset], createdTimelineServices);
|
||||
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
|
||||
when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => null);
|
||||
when(() => timelineRepository.getMainTimelineIndexByChecksum(['user-1'], 'checksum-1')).thenAnswer((_) async => 0);
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService);
|
||||
|
||||
expect(result.asset, same(mainAsset));
|
||||
expect(result.timelineService, same(mainTimelineService));
|
||||
verifyNever(() => nativeSyncApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('computes checksum for local asset when db checksum is missing', () async {
|
||||
final localAsset = _localAsset(id: 'local-1', checksum: null);
|
||||
final mainAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1');
|
||||
mainTimelineService = await _setMainTimelineService([mainAsset], createdTimelineServices);
|
||||
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
|
||||
when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => null);
|
||||
when(
|
||||
() => nativeSyncApi.hashAssets(['local-1'], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: 'local-1', hash: 'checksum-1')]);
|
||||
when(() => timelineRepository.getMainTimelineIndexByChecksum(['user-1'], 'checksum-1')).thenAnswer((_) async => 0);
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService);
|
||||
|
||||
expect(result.asset, same(mainAsset));
|
||||
expect(result.timelineService, same(mainTimelineService));
|
||||
verify(() => nativeSyncApi.hashAssets(['local-1'], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
|
||||
test('returns deep-link local asset when no main timeline match is found', () async {
|
||||
final localAsset = _localAsset(id: 'local-1', checksum: null);
|
||||
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
|
||||
when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => null);
|
||||
when(() => nativeSyncApi.hashAssets(['local-1'], allowNetworkAccess: false)).thenThrow(Exception('hash failed'));
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService);
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
|
||||
|
||||
expect(result.asset, equals(localAsset));
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.initialIndex, 0);
|
||||
expect(result.viewIntentFilePath, isNull);
|
||||
expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed');
|
||||
});
|
||||
|
||||
test('matches path-only attachment to main timeline by checksum', () async {
|
||||
final mainAsset = _remoteAsset(id: 'remote-2', checksum: 'checksum-2');
|
||||
mainTimelineService = await _setMainTimelineService([mainAsset], createdTimelineServices);
|
||||
test('returns transient asset with temp file path when localAssetId has no DB row', () async {
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null);
|
||||
|
||||
when(
|
||||
() => nativeSyncApi.hashFiles(['/tmp/incoming.jpg']),
|
||||
).thenAnswer((_) async => [HashResult(assetId: '/tmp/incoming.jpg', hash: 'checksum-2')]);
|
||||
when(() => timelineRepository.getMainTimelineIndexByChecksum(['user-1'], 'checksum-2')).thenAnswer((_) async => 0);
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg'));
|
||||
|
||||
final result = await _resolve(
|
||||
container,
|
||||
_payload(path: '/tmp/incoming.jpg', localAssetId: null),
|
||||
mainTimelineService,
|
||||
);
|
||||
|
||||
expect(result.asset, same(mainAsset));
|
||||
expect(result.timelineService, same(mainTimelineService));
|
||||
expect(result.viewIntentFilePath, isNull);
|
||||
expect(result.asset, isA<LocalAsset>());
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, '/tmp/incoming.jpg');
|
||||
});
|
||||
|
||||
test('returns transient deep-link asset for unmatched path-only attachment', () async {
|
||||
when(() => nativeSyncApi.hashFiles(['/tmp/incoming.webp'])).thenAnswer((_) async => const []);
|
||||
|
||||
test('returns transient asset for path-only attachment', () async {
|
||||
final result = await _resolve(
|
||||
container,
|
||||
_payload(path: '/tmp/incoming.webp', localAssetId: null, mimeType: 'image/webp'),
|
||||
mainTimelineService,
|
||||
_payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'),
|
||||
);
|
||||
|
||||
expect(result.asset, isA<LocalAsset>());
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.initialIndex, 0);
|
||||
expect(result.viewIntentFilePath, '/tmp/incoming.webp');
|
||||
|
||||
final asset = result.asset as LocalAsset;
|
||||
expect(asset.localId, startsWith('-'));
|
||||
expect(asset.name, 'incoming.webp');
|
||||
expect(asset.checksum, isNull);
|
||||
expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated);
|
||||
});
|
||||
|
||||
test('throws when neither localAssetId nor path is provided', () async {
|
||||
await expectLater(
|
||||
_resolve(container, _payload(localAssetId: null, path: null)),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<ViewIntentResolvedAsset> _resolve(
|
||||
ProviderContainer container,
|
||||
ViewIntentPayload payload,
|
||||
TimelineService mainTimelineService,
|
||||
) {
|
||||
return container
|
||||
.read(viewIntentAssetResolverProvider)
|
||||
.resolve(payload, timelineUsers: const ['user-1'], mainTimelineService: mainTimelineService);
|
||||
Future<ViewIntentResolvedAsset> _resolve(ProviderContainer container, ViewIntentPayload payload) {
|
||||
return container.read(viewIntentAssetResolverProvider).resolve(payload);
|
||||
}
|
||||
|
||||
ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) {
|
||||
@@ -200,20 +114,6 @@ LocalAsset _localAsset({required String id, String? checksum}) {
|
||||
);
|
||||
}
|
||||
|
||||
RemoteAsset _remoteAsset({required String id, String? localId, String? checksum}) {
|
||||
return RemoteAsset(
|
||||
id: id,
|
||||
localId: localId,
|
||||
ownerId: 'user-1',
|
||||
name: '$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 4, 20),
|
||||
updatedAt: DateTime(2026, 4, 20),
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
||||
@@ -221,22 +121,3 @@ TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigi
|
||||
origin: origin,
|
||||
));
|
||||
}
|
||||
|
||||
Future<TimelineService> _createReadyTimelineService(List<BaseAsset> assets, TimelineOrigin origin) async {
|
||||
final timelineService = _timelineServiceFromAssets(assets, origin);
|
||||
|
||||
for (var i = 0; i < 20 && !timelineService.isReady; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
return timelineService;
|
||||
}
|
||||
|
||||
Future<TimelineService> _setMainTimelineService(
|
||||
List<BaseAsset> assets,
|
||||
List<TimelineService> createdTimelineServices,
|
||||
) async {
|
||||
final timelineService = await _createReadyTimelineService(assets, TimelineOrigin.main);
|
||||
createdTimelineServices.add(timelineService);
|
||||
return timelineService;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user