mirror of
https://github.com/immich-app/immich.git
synced 2026-06-16 11:52:16 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c97108329e | |||
| b21af78454 | |||
| abd62d9295 |
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user