Compare commits

...

3 Commits

Author SHA1 Message Date
shenlong-tanwen c97108329e fix: use setRequireOriginal on SDK 29 and above 2026-06-14 07:33:56 +05:30
maxinegardenas b21af78454 fix(web): correctly handle person search with more than 100 results (#29002) 2026-06-12 20:04:52 +00:00
Santo Shakil abd62d9295 fix(mobile): show like and comment options on album photo deep links (#29020) 2026-06-12 14:55:26 -05:00
5 changed files with 178 additions and 10 deletions
@@ -2,6 +2,7 @@ package app.alextran.immich.sync
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
@@ -10,6 +11,7 @@ import androidx.annotation.RequiresExtension
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.serialization.json.Json
import java.io.InputStream
@RequiresApi(Build.VERSION_CODES.Q)
@RequiresExtension(extension = Build.VERSION_CODES.R, version = 1)
@@ -124,4 +126,12 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
return result.mapValues { it.value.toList() }
}
override fun openOriginalStream(uri: Uri): InputStream? {
return try {
ctx.contentResolver.openInputStream(MediaStore.setRequireOriginal(uri))
} catch (_: Exception) {
super.openOriginalStream(uri)
}
}
}
@@ -4,7 +4,7 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import androidx.exifinterface.media.ExifInterface
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.ext.SdkExtensions
@@ -30,6 +30,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File
import java.io.InputStream
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
@@ -275,7 +276,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
// if mimeType is webp but not animated, its just an image.
// if mimeType is webp but not animated, it's just an image.
return PlatformAssetPlaybackStyle.IMAGE
}
@@ -377,7 +378,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)?.use { cursor -> cursor.count.toLong() } ?: 0L
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) {
fun getAssetsForAlbum(
albumId: String,
updatedTimeCond: Long?,
callback: (Result<List<PlatformAsset>>) -> Unit
) {
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
}
@@ -419,7 +424,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
}.awaitAll()
completeWhenActive(callback, Result.success(results))
} catch (e: CancellationException) {
} catch (_: CancellationException) {
completeWhenActive(
callback, Result.failure(
FlutterError(
@@ -435,6 +440,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
}
}
protected open fun openOriginalStream(uri: Uri): InputStream? {
return ctx.contentResolver.openInputStream(uri)
}
private suspend fun hashAsset(assetId: String): HashResult {
return try {
val assetUri = ContentUris.withAppendedId(
@@ -443,7 +452,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)
val digest = MessageDigest.getInstance("SHA-1")
ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
openOriginalStream(assetUri)?.use { inputStream ->
var bytesRead: Int
val buffer = ByteArray(HASH_BUFFER_SIZE)
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
@@ -476,8 +485,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
syncJob = CoroutineScope(Dispatchers.IO).launch {
try {
completeWhenActive(callback, Result.success(work()))
} catch (e: CancellationException) {
completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
} catch (_: CancellationException) {
completeWhenActive(
callback,
Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null))
)
} catch (e: Exception) {
completeWhenActive(callback, Result.failure(e))
}
+8 -2
View File
@@ -70,7 +70,10 @@ class DeepLinkService {
if (assetRegex.hasMatch(path)) {
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
return _buildAssetDeepLink(assetId, ref);
// /albums/<albumId>/photos/<assetId> links carry the album context,
// which drives the like/comment UI in the viewer
final albumId = albumRegex.firstMatch(path)?.group(1);
return _buildAssetDeepLink(assetId, ref, albumId: albumId);
}
if (albumRegex.hasMatch(path)) {
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
@@ -107,16 +110,19 @@ class DeepLinkService {
return DriftMemoryRoute(memories: memories, memoryIndex: 0);
}
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref) async {
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref, {String? albumId}) async {
final asset = await _betaAssetService.getRemoteAsset(assetId);
if (asset == null) {
return null;
}
final album = albumId != null ? await _betaRemoteAlbumService.get(albumId) : null;
AssetViewer.setAsset(ref, asset);
return AssetViewerRoute(
initialIndex: 0,
timelineService: _betaTimelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
currentAlbum: album,
);
}
@@ -0,0 +1,140 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.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/asset.service.dart';
import 'package:immich_mobile/domain/services/memory.service.dart';
import 'package:immich_mobile/domain/services/people.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.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/routing/router.dart';
import 'package:immich_mobile/services/deep_link.service.dart';
import 'package:mocktail/mocktail.dart';
class MockTimelineFactory extends Mock implements TimelineFactory {}
class MockAssetService extends Mock implements AssetService {}
class MockRemoteAlbumService extends Mock implements RemoteAlbumService {}
class MockDriftMemoryService extends Mock implements DriftMemoryService {}
class MockDriftPeopleService extends Mock implements DriftPeopleService {}
class MockPlatformDeepLink extends Mock implements PlatformDeepLink {}
class MockWidgetRef extends Mock implements WidgetRef {}
class MockAssetViewerStateNotifier extends Mock implements AssetViewerStateNotifier {}
const _assetId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb';
const _albumId = 'cccccccc-4444-5555-6666-dddddddddddd';
final _asset = RemoteAsset(
id: _assetId,
name: 'photo.jpg',
ownerId: 'user-1',
checksum: 'checksum-1',
type: AssetType.image,
createdAt: DateTime(2026, 6, 12),
updatedAt: DateTime(2026, 6, 12),
isEdited: false,
);
final _album = RemoteAlbum(
id: _albumId,
name: 'Shared Album',
ownerId: 'user-1',
description: '',
createdAt: DateTime(2026, 6, 12),
updatedAt: DateTime(2026, 6, 12),
isActivityEnabled: true,
isShared: true,
order: AlbumAssetOrder.asc,
assetCount: 1,
ownerName: 'Owner',
);
void main() {
late MockTimelineFactory timelineFactory;
late MockAssetService assetService;
late MockRemoteAlbumService remoteAlbumService;
late MockWidgetRef ref;
late List<TimelineService> createdTimelineServices;
late DeepLinkService sut;
setUp(() {
timelineFactory = MockTimelineFactory();
assetService = MockAssetService();
remoteAlbumService = MockRemoteAlbumService();
ref = MockWidgetRef();
createdTimelineServices = [];
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
final timelineService = TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
origin: TimelineOrigin.deepLink,
));
createdTimelineServices.add(timelineService);
return timelineService;
});
when(() => ref.read(assetViewerProvider.notifier)).thenReturn(MockAssetViewerStateNotifier());
sut = DeepLinkService(
timelineFactory,
assetService,
remoteAlbumService,
MockDriftMemoryService(),
MockDriftPeopleService(),
null,
);
addTearDown(() async {
for (final timelineService in createdTimelineServices) {
await timelineService.dispose();
}
});
});
PlatformDeepLink link(String path) {
final deepLink = MockPlatformDeepLink();
when(() => deepLink.uri).thenReturn(Uri.parse('https://my.immich.app$path'));
return deepLink;
}
test('album photo link carries the album into the viewer route', () async {
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => _album);
final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref);
expect(route, isA<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, _album);
});
test('still opens the viewer when the album cannot be resolved', () async {
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => null);
final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref);
expect(route, isA<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull);
});
test('plain photo link has no album', () async {
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
final route = await sut.handleMyImmichApp(link('/photos/$_assetId'), ref);
expect(route, isA<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull);
verifyNever(() => remoteAlbumService.get(any()));
});
}
+1 -1
View File
@@ -69,7 +69,7 @@ export enum OpenQueryParam {
PURCHASE_SETTINGS = 'user-purchase-settings',
}
export const maximumLengthSearchPeople = 1000;
export const maximumLengthSearchPeople = 100;
// time to load the map before displaying the loading spinner
export const timeToLoadTheMap: number = 100;