This commit is contained in:
shenlong-tanwen
2026-05-23 05:41:14 +05:30
parent 43554fc6cf
commit 76c47f8f57
33 changed files with 480 additions and 1989 deletions
@@ -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
@@ -1,14 +1,14 @@
package app.alextran.immich.viewintent
import android.app.Activity
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.net.Uri
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
@@ -26,7 +26,7 @@ private const val TAG = "ViewIntentPlugin"
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
private var context: Context? = null
private var activity: Activity? = null
private var pendingIntent: Intent? = null
private var unconsumedIntent: Intent? = null
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -42,7 +42,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
pendingIntent = binding.activity.intent
unconsumedIntent = binding.activity.intent
binding.addOnNewIntentListener(this)
}
@@ -59,7 +59,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
}
override fun onNewIntent(intent: Intent): Boolean {
pendingIntent = intent
unconsumedIntent = intent
return false
}
@@ -68,7 +68,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
callback(Result.success(null))
return
}
val intent = pendingIntent ?: activity?.intent
val intent = unconsumedIntent ?: activity?.intent
if (intent?.action != Intent.ACTION_VIEW) {
callback(Result.success(null))
@@ -83,7 +83,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
@@ -112,108 +112,40 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
}
private fun consumeViewIntent(currentIntent: Intent) {
pendingIntent = Intent(currentIntent).apply {
unconsumedIntent = Intent(currentIntent).apply {
action = null
data = null
type = null
}
activity?.intent = pendingIntent
activity?.intent = unconsumedIntent
}
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)
?: 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
}
val parsed = docId.substringAfter(':', docId)
if (parsed.all(Char::isDigit)) {
return parsed
}
return MediaStoreUtils.resolveLocalIdByRelativePath(context, parsed, mimeType)
if (docId.isBlank() || docId.startsWith("raw:")) return null
docId.substringAfter(':', docId).toLongOrNull()?.toString()
} catch (e: Exception) {
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
return null
}
}
private fun tryParseContentUriId(uri: Uri): String? {
return try {
val parsed = ContentUris.parseId(uri)
if (parsed >= 0) parsed.toString() else null
} catch (e: Exception) {
Log.w(TAG, "Failed to parse local asset id from content URI: $uri", e)
null
}
}
private fun tryParseLastPathSegmentId(uri: Uri): String? {
val segment = uri.lastPathSegment ?: return null
return if (segment.all(Char::isDigit)) segment else null
private fun tryParseContentUriId(uri: Uri): String? {
val id = uri.lastPathSegment?.toLongOrNull() ?: return null
return if (id >= 0) id.toString() else null
}
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 +157,45 @@ 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 -> return null
}
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
}
}
}
-20
View File
@@ -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
@@ -318,13 +318,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()
@@ -17,8 +17,6 @@ typedef TimelineBucketSource = Stream<List<Bucket>> Function();
typedef TimelineQuery = ({TimelineAssetSource assetSource, TimelineBucketSource bucketSource, TimelineOrigin origin});
enum TimelineStatus { uninitialized, ready, disposed }
enum TimelineOrigin {
main,
localAlbum,
@@ -103,13 +101,9 @@ class TimelineService {
int _bufferOffset = 0;
List<BaseAsset> _buffer = [];
StreamSubscription? _bucketSubscription;
final StreamController<TimelineStatus> _statusController = StreamController<TimelineStatus>.broadcast();
int _totalAssets = 0;
int get totalAssets => _totalAssets;
TimelineStatus _status = TimelineStatus.uninitialized;
TimelineStatus get status => _status;
bool get isReady => _status == TimelineStatus.ready;
TimelineService(TimelineQuery query)
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
@@ -145,17 +139,12 @@ class TimelineService {
// change the state's total assets count only after the buffer is reloaded
_totalAssets = totalAssets;
if (_status == TimelineStatus.uninitialized) {
_status = TimelineStatus.ready;
_statusController.add(_status);
}
EventStream.shared.emit(const TimelineReloadEvent());
});
});
}
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
Stream<TimelineStatus> watchStatus() => _statusController.stream;
Future<List<BaseAsset>> loadAssets(int index, int count) => _mutex.run(() => _loadAssets(index, count));
@@ -258,12 +247,5 @@ class TimelineService {
_bucketSubscription = null;
_buffer = [];
_bufferOffset = 0;
if (_status != TimelineStatus.disposed) {
_status = TimelineStatus.disposed;
if (!_statusController.isClosed) {
_statusController.add(_status);
}
}
await _statusController.close();
}
}
@@ -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');
@@ -678,36 +678,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
}
Future<int?> getMainTimelineIndexByChecksum(List<String> userIds, String checksum) async {
if (userIds.isEmpty) {
return null;
}
final result = await _db.mergedAssetDrift
.mergedAssetIndexByChecksum(userIds: userIds, checksum: checksum)
.getSingleOrNull();
return result;
}
Future<int?> getMainTimelineIndexByLocalId(List<String> userIds, String localAssetId) async {
if (userIds.isEmpty) {
return null;
}
final result = await _db.mergedAssetDrift
.mergedAssetIndexByLocalId(userIds: userIds, localAssetId: localAssetId)
.getSingleOrNull();
return result;
}
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) {
+35 -35
View File
@@ -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',
@@ -26,6 +34,8 @@ Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName,
return replyList.firstOrNull;
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -52,50 +62,35 @@ class LocalImageApi {
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
LocalImageApi({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();
final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(
String assetId, {
required int requestId,
required int width,
required int height,
required bool isVideo,
required bool preferEncoded,
}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
Future<Map<String, int>?> requestImage(String assetId, {required int requestId, required int width, required int height, required bool isVideo, required bool preferEncoded, }) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
assetId,
requestId,
width,
height,
isVideo,
preferEncoded,
]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, requestId, width, height, isVideo, preferEncoded]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
return (pigeonVar_replyValue as Map<Object?, Object?>?)?.cast<String, int>();
}
Future<void> cancelRequest(int requestId) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -104,12 +99,16 @@ class LocalImageApi {
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
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, int>> getThumbhash(String thumbhash) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -119,10 +118,11 @@ class LocalImageApi {
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, int>();
}
}
+170 -145
View File
@@ -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>();
}
}
+36 -19
View File
@@ -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,8 +96,13 @@ int _deepHash(Object? value) {
return value.hashCode;
}
class ViewIntentPayload {
ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
ViewIntentPayload({
this.path,
required this.mimeType,
this.localAssetId,
});
String? path;
@@ -96,12 +111,15 @@ class ViewIntentPayload {
String? localAssetId;
List<Object?> _toList() {
return <Object?>[path, mimeType, localAssetId];
return <Object?>[
path,
mimeType,
localAssetId,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static ViewIntentPayload decode(Object result) {
result as List<Object?>;
@@ -121,9 +139,7 @@ class ViewIntentPayload {
if (identical(this, other)) {
return true;
}
return _deepEquals(path, other.path) &&
_deepEquals(mimeType, other.mimeType) &&
_deepEquals(localAssetId, other.localAssetId);
return _deepEquals(path, other.path) && _deepEquals(mimeType, other.mimeType) && _deepEquals(localAssetId, other.localAssetId);
}
@override
@@ -131,6 +147,7 @@ class ViewIntentPayload {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -138,7 +155,7 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is ViewIntentPayload) {
} else if (value is ViewIntentPayload) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
@@ -162,8 +179,8 @@ class ViewIntentHostApi {
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
ViewIntentHostApi({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();
@@ -171,8 +188,7 @@ class ViewIntentHostApi {
final String pigeonVar_messageChannelSuffix;
Future<ViewIntentPayload?> consumeViewIntent() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -182,10 +198,11 @@ class ViewIntentHostApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
return pigeonVar_replyValue as ViewIntentPayload?;
}
}
@@ -1,25 +1,16 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
@RoutePage()
class MainTimelinePage extends HookConsumerWidget {
class MainTimelinePage extends ConsumerWidget {
const MainTimelinePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
useEffect(() {
unawaited(Future<void>(() => ref.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce()));
return null;
}, const []);
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
@@ -8,13 +8,12 @@ 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/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
@@ -61,36 +60,32 @@ class UploadActionButton extends ConsumerWidget {
}
var success = false;
String? uploadedRemoteAssetId;
if (!isTimeline && viewerIntentFilePath != null) {
final viewIntentService = ref.read(viewIntentServiceProvider);
viewIntentService.markUploadActive(viewerIntentFilePath);
var hasError = false;
await ref
.read(foregroundUploadServiceProvider)
.uploadShareIntent(
[File(viewerIntentFilePath)],
onSuccess: (_, remoteAssetId) {
uploadedRemoteAssetId = remoteAssetId;
},
onError: (fileId, errorMessage) {
hasError = true;
},
);
try {
await ref
.read(foregroundUploadServiceProvider)
.uploadShareIntent(
[File(viewerIntentFilePath)],
onError: (_, _) {
hasError = true;
},
);
} finally {
await viewIntentService.markUploadInactive(viewerIntentFilePath);
}
success = !hasError;
} 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,
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
@@ -328,9 +327,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required String? localFilePath,
}) {
final size = context.sizeData;
final imageProvider = localFilePath != null
? FileImage(File(localFilePath))
: getFullImageProvider(asset, size: size);
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView(
@@ -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';
@@ -65,7 +64,18 @@ class AssetViewer extends ConsumerStatefulWidget {
ConsumerState createState() => _AssetViewerState();
static void setAsset(WidgetRef ref, BaseAsset asset) {
prepareAssetViewerState(ref.read(assetViewerProvider.notifier), asset);
ref.read(assetViewerProvider.notifier).reset();
// Hide controls by default for videos
if (asset.isVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
_setAsset(ref, asset);
}
static void _setAsset(WidgetRef ref, BaseAsset asset) {
ref.read(assetViewerProvider.notifier).setAsset(asset);
}
}
@@ -79,8 +89,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
MainTimelineHandoffCoordinator? _mainTimelineHandoffCoordinator;
bool _disposeStarted = false;
void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt();
@@ -99,9 +107,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) {
@@ -118,8 +123,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
@override
void dispose() {
_disposeStarted = true;
_mainTimelineHandoffCoordinator?.cancel();
_pageController.dispose();
_preloader.dispose();
_reloadSubscription?.cancel();
@@ -157,17 +160,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onAssetChanged(int index) async {
if (!mounted) {
return;
}
_currentPage = index;
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
if (!mounted || asset == null) {
if (asset == null) {
return;
}
ref.read(assetViewerProvider.notifier).setAsset(asset);
AssetViewer._setAsset(ref, asset);
_preloader.preload(index, context.sizeData);
_handleCasting();
_stackChildrenKeepAlive?.close();
@@ -203,9 +203,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onEvent(Event event) {
if (!mounted || _disposeStarted) {
return;
}
switch (event) {
case TimelineReloadEvent():
_onTimelineReloadEvent();
@@ -229,20 +226,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider);
final totalAssets = timelineService.totalAssets;
final currentAsset = ref.read(assetViewerProvider).currentAsset;
final isViewerTransitionInProgress = ref.read(
assetViewerProvider.select((value) => value.isViewerTransitionInProgress),
);
if (isViewerTransitionInProgress) {
return;
}
if (totalAssets == 0) {
context.maybePop();
return;
}
final currentAsset = ref.read(assetViewerProvider).currentAsset;
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
@@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:async/async.dart';
@@ -146,10 +147,17 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
}
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
ImageProvider getFullImageProvider(
BaseAsset asset, {
Size size = const Size(1080, 1920),
bool edited = true,
String? localFilePath,
}) {
// Create new provider and cache it
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
if (localFilePath != null) {
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
} else {
@@ -10,7 +10,6 @@ class AssetViewerState {
final bool isZoomed;
final BaseAsset? currentAsset;
final int stackIndex;
final bool isViewerTransitionInProgress;
const AssetViewerState({
this.backgroundOpacity = 1.0,
@@ -19,7 +18,6 @@ class AssetViewerState {
this.isZoomed = false,
this.currentAsset,
this.stackIndex = 0,
this.isViewerTransitionInProgress = false,
});
AssetViewerState copyWith({
@@ -29,7 +27,6 @@ class AssetViewerState {
bool? isZoomed,
BaseAsset? currentAsset,
int? stackIndex,
bool? isViewerTransitionInProgress,
}) {
return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
@@ -38,7 +35,6 @@ class AssetViewerState {
isZoomed: isZoomed ?? this.isZoomed,
currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex,
isViewerTransitionInProgress: isViewerTransitionInProgress ?? this.isViewerTransitionInProgress,
);
}
@@ -61,8 +57,7 @@ class AssetViewerState {
other.showingControls == showingControls &&
other.isZoomed == isZoomed &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex &&
other.isViewerTransitionInProgress == isViewerTransitionInProgress;
other.stackIndex == stackIndex;
}
@override
@@ -72,8 +67,7 @@ class AssetViewerState {
showingControls.hashCode ^
isZoomed.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode ^
isViewerTransitionInProgress.hashCode;
stackIndex.hashCode;
}
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@@ -143,28 +137,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
}
state = state.copyWith(stackIndex: index);
}
void setViewerTransitionInProgress(bool isInProgress) {
if (isInProgress == state.isViewerTransitionInProgress) {
return;
}
state = state.copyWith(isViewerTransitionInProgress: isInProgress);
}
}
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
void prepareAssetViewerState(AssetViewerStateNotifier notifier, BaseAsset asset) {
notifier.reset();
// Hide controls by default for videos before the viewer is shown.
if (asset.isVideo) {
notifier.setControls(false);
}
notifier.setAsset(asset);
}
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
final asset = ref.read(assetViewerProvider).currentAsset;
@@ -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});
}
@@ -41,23 +41,3 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
});
final timelineStatusProvider = StreamProvider.autoDispose.family<TimelineStatus, TimelineService>((
ref,
timelineService,
) async* {
yield timelineService.status;
yield* timelineService.watchStatus();
});
Future<void> waitForTimelineReady(TimelineService timelineService, Duration timeout) {
if (timelineService.isReady) {
return Future.value();
}
return timelineService
.watchStatus()
.firstWhere((status) => status == TimelineStatus.ready)
.timeout(timeout)
.then((_) {});
}
@@ -1,16 +1,13 @@
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';
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
@@ -55,18 +52,8 @@ class AndroidViewIntentHandler implements ViewIntentHandler {
}
Future<void> _flushPending() async {
if (_ref.read(viewIntentPendingProvider) == null) {
return;
}
try {
await _ref.read(viewIntentMainTimelineReadyProvider.notifier).wait(timeout: const Duration(seconds: 3));
} catch (_) {
return;
}
final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
_logger.info('flushPending, pendingAttachment:$pendingAttachment}');
_logger.info('flushPending, pendingAttachment:$pendingAttachment');
if (pendingAttachment != null) {
await handle(pendingAttachment);
}
@@ -83,66 +70,34 @@ 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);
unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
} else {
_ref.read(viewIntentFilePathProvider.notifier).clear();
unawaited(_viewIntentService.cleanupManagedTempFile());
}
unawaited(_router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService)));
await _waitForNextFrame();
} finally {
notifier.setViewerTransitionInProgress(false);
notifier.reset();
if (asset.isVideo) {
notifier.setControls(false);
}
}
notifier.setAsset(asset);
Future<void> _waitForNextFrame() {
final completer = Completer<void>();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!completer.isCompleted) {
completer.complete();
}
});
return completer.future;
if (viewIntentFilePath != null) {
_ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
} else {
_ref.read(viewIntentFilePathProvider.notifier).clear();
unawaited(_viewIntentService.cleanupManagedTempFile());
}
await _router.replaceAll([
const TabShellRoute(),
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
]);
}
}
@@ -1,59 +0,0 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
final viewIntentMainTimelineReadyProvider = NotifierProvider<ViewIntentMainTimelineReadyNotifier, bool>(
ViewIntentMainTimelineReadyNotifier.new,
);
class ViewIntentMainTimelineReadyNotifier extends Notifier<bool> {
Completer<void>? _readyCompleter;
bool _hasSeenMainTimeline = false;
bool _hasTimelineUsers = false;
bool _isTimelineReady = false;
@override
bool build() {
_readyCompleter ??= Completer<void>();
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull;
final timelineService = ref.watch(timelineServiceProvider);
final timelineStatus = ref.watch(timelineStatusProvider(timelineService)).valueOrNull ?? timelineService.status;
_hasTimelineUsers = timelineUsers != null && timelineUsers.isNotEmpty;
_isTimelineReady = timelineStatus == TimelineStatus.ready;
final isReady = _computeReady();
_completeWaitersIfReady(isReady);
return isReady;
}
Future<void> wait({required Duration timeout}) {
if (state) {
return Future.value();
}
return _readyCompleter!.future.timeout(timeout);
}
void markMountedOnce() {
_hasSeenMainTimeline = true;
final isReady = _computeReady();
state = isReady;
_completeWaitersIfReady(isReady);
}
bool _computeReady() => _hasSeenMainTimeline && _hasTimelineUsers && _isTimelineReady;
void _completeWaitersIfReady(bool isReady) {
if (isReady) {
if (!(_readyCompleter?.isCompleted ?? true)) {
_readyCompleter?.complete();
}
} else if (_readyCompleter?.isCompleted ?? true) {
_readyCompleter = Completer<void>();
}
}
}
+20 -1
View File
@@ -11,6 +11,7 @@ class ViewIntentService {
final ViewIntentHostApi _viewIntentHostApi;
final Future<Directory> Function() _temporaryDirectory;
String? _managedTempFilePath;
final Set<String> _activeUploadPaths = {};
ViewIntentService(this._viewIntentHostApi, {Future<Directory> Function()? temporaryDirectory})
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
@@ -54,6 +55,9 @@ class ViewIntentService {
if (!_isManagedTempFile(path)) {
return;
}
if (_activeUploadPaths.contains(path)) {
return;
}
try {
final file = File(path);
@@ -74,7 +78,9 @@ class ViewIntentService {
}
final path = entity.path;
if (!_isManagedTempFile(path) || path == _managedTempFilePath) {
if (!_isManagedTempFile(path) ||
path == _managedTempFilePath ||
_activeUploadPaths.contains(path)) {
continue;
}
@@ -85,6 +91,19 @@ class ViewIntentService {
}
}
void markUploadActive(String path) {
_activeUploadPaths.add(path);
}
Future<void> markUploadInactive(String path) async {
if (!_activeUploadPaths.remove(path)) {
return;
}
if (_managedTempFilePath != path) {
await cleanupTempFile(path);
}
}
bool _isManagedTempFile(String path) {
return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache';
}
@@ -1,270 +1,64 @@
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;
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),
),
);
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;
}
}
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: 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.
// The viewer currently expects a BaseAsset, so this temporary LocalAsset
// 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,
+2 -2
View File
@@ -29,8 +29,8 @@ run = [
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
"dart run pigeon --input pigeon/connectivity_api.dart",
"dart run pigeon --input pigeon/network_api.dart",
"dart run pigeon --input pigeon/view_intent_api.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
"dart run pigeon --input pigeon/view_intent_api.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
]
[tasks."codegen:translation"]
+1 -2
View File
@@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart';
dartOut: 'lib/platform/local_image_api.g.dart',
swiftOut: 'ios/Runner/Images/LocalImages.g.swift',
swiftOptions: SwiftOptions(includeErrorClass: false),
kotlinOut:
'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
-4
View File
@@ -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,
);
}
@@ -10,9 +10,7 @@ import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/platform/view_intent_api.g.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_handler_android.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
@@ -88,8 +86,6 @@ class TestAuthNotifier extends AuthNotifier {
}
}
bool _pageRoutePredicate(PageRouteInfo<dynamic> route) => false;
final _handlerProvider = Provider<AndroidViewIntentHandler>((ref) => AndroidViewIntentHandler(ref));
void main() {
@@ -107,10 +103,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,16 +118,13 @@ 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),
timelineServiceProvider.overrideWithValue(deepLinkTimelineService),
timelineUsersProvider.overrideWith((ref) => Stream.value(['user-1'])),
authProvider.overrideWith((ref) {
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
return authNotifier;
@@ -139,7 +133,6 @@ void main() {
);
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
await container.read(timelineUsersProvider.future);
handler = container.read(_handlerProvider);
addTearDown(() async {
@@ -154,65 +147,31 @@ 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', (
tester,
) async {
testWidgets('flushDeferredViewIntent consumes the pending attachment and routes the viewer', (tester) async {
authNotifier.setAuthenticated(false);
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'),
),
);
container.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce();
await tester.pump();
await tester.pump();
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 +180,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 +190,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 +205,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);
});
}
@@ -306,10 +252,10 @@ TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigi
Future<TimelineService> _createReadyTimelineService(List<BaseAsset> assets, TimelineOrigin origin) async {
final timelineService = _timelineServiceFromAssets(assets, origin);
if (!timelineService.isReady) {
await timelineService.watchStatus().firstWhere((status) => status == TimelineStatus.ready);
// Spin a few async ticks so the internal bucket subscription has populated
// the buffer before tests start asserting against totalAssets.
for (var i = 0; i < 20 && timelineService.totalAssets != assets.length; i++) {
await Future<void>.delayed(Duration.zero);
}
return timelineService;
}
@@ -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;
}
@@ -74,6 +74,18 @@ void main() {
expect(await secondFile.exists(), isFalse);
});
test('cleanupTempFile defers deletion while an upload is active', () async {
final tempFile = File('${cacheDir.path}/view_intent_in_flight.jpg')..writeAsStringSync('bytes');
service.markUploadActive(tempFile.path);
await service.cleanupTempFile(tempFile.path);
expect(await tempFile.exists(), isTrue, reason: 'active uploads block cleanup');
await service.markUploadInactive(tempFile.path);
expect(await tempFile.exists(), isFalse);
});
test('cleanupTempFile ignores non-managed paths', () async {
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
@@ -93,4 +105,15 @@ void main() {
expect(await secondFile.exists(), isFalse);
expect(await unrelatedFile.exists(), isTrue);
});
test('cleanupStaleTempFiles skips paths with active uploads', () async {
final stale = File('${cacheDir.path}/view_intent_stale.jpg')..writeAsStringSync('stale');
final active = File('${cacheDir.path}/view_intent_active.jpg')..writeAsStringSync('active');
service.markUploadActive(active.path);
await service.cleanupStaleTempFiles();
expect(await stale.exists(), isFalse);
expect(await active.exists(), isTrue);
});
}