This commit is contained in:
Santo Shakil 2026-08-15 12:41:07 +06:00 committed by GitHub
commit 6fa1d8a132
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 278 additions and 11 deletions

View file

@ -113,7 +113,8 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
} }
getCursor(volume, queryArgs).use { cursor -> val cursor = getCursor(volume, queryArgs) ?: error("MediaStore trash query failed")
cursor.use {
getAssets(cursor).forEach { res -> getAssets(cursor).forEach { res ->
if (res is AssetResult.ValidAsset) { if (res is AssetResult.ValidAsset) {
result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset) result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset)

View file

@ -1,8 +1,10 @@
package app.alextran.immich.sync package app.alextran.immich.sync
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor import android.database.Cursor
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@ -10,6 +12,7 @@ import android.os.ext.SdkExtensions
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.ImmichPlugin
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -108,6 +111,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21) SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21)
} }
fun hasMediaReadPermission(): Boolean =
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO)
} else arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE))
.all { ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED }
protected fun getCursor( protected fun getCursor(
volume: String, volume: String,
selection: String, selection: String,
@ -175,11 +184,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
else -> 0L else -> 0L
} }
// Date taken is milliseconds since epoch, Date added is seconds since epoch // Date taken is in ms; added/modified are in seconds, and modified can be 0 when unset.
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) // If EXIF date taken exists use it, else if modified is empty use added, else the earliest of the two.
?: c.getLong(dateAddedColumn)
// Date modified is seconds since epoch
val modifiedAt = c.getLong(dateModifiedColumn) val modifiedAt = c.getLong(dateModifiedColumn)
val addedAt = c.getLong(dateAddedColumn)
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
?: if (modifiedAt <= 0) addedAt else minOf(modifiedAt, addedAt)
val width = c.getInt(widthColumn).toLong() val width = c.getInt(widthColumn).toLong()
val height = c.getInt(heightColumn).toLong() val height = c.getInt(heightColumn).toLong()
// Duration is milliseconds // Duration is milliseconds
@ -314,13 +324,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
val selection = val selection =
"(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION" "(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION"
getCursor( val cursor = getCursor(
MediaStore.VOLUME_EXTERNAL, MediaStore.VOLUME_EXTERNAL,
selection, selection,
MEDIA_SELECTION_ARGS, MEDIA_SELECTION_ARGS,
projection, projection,
"${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC"
)?.use { cursor -> ) ?: error("MediaStore album query failed")
cursor.use {
val bucketIdColumn = val bucketIdColumn =
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_ID) cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_ID)
val bucketNameColumn = val bucketNameColumn =
@ -395,7 +406,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
selectionArgs.addAll(listOf(updatedTimeCond.toString(), updatedTimeCond.toString())) selectionArgs.addAll(listOf(updatedTimeCond.toString(), updatedTimeCond.toString()))
} }
return getAssets(getCursor(MediaStore.VOLUME_EXTERNAL, selection, selectionArgs.toTypedArray())) val cursor = getCursor(MediaStore.VOLUME_EXTERNAL, selection, selectionArgs.toTypedArray())
?: error("MediaStore asset query failed")
return getAssets(cursor)
.mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset } .mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset }
.toList() .toList()
} }

View file

@ -46,6 +46,8 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
init(with defaults: UserDefaults = .standard) { init(with defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
} }
func hasMediaReadPermission() throws -> Bool { PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized }
@available(iOS 16, *) @available(iOS 16, *)
private func getChangeToken() -> PHPersistentChangeToken? { private func getChangeToken() -> PHPersistentChangeToken? {

View file

@ -21,6 +21,8 @@ import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@ -55,7 +57,7 @@ void main() async {
await initApp(); await initApp();
// Warm-up isolate pool for worker manager // Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(drift); await migrateDatabaseIfNeeded(drift, NativeSyncApi(), PermissionApi());
runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget())); runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget()));
} catch (error, stack) { } catch (error, stack) {

View file

@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
@ -13,16 +14,22 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/feature_message.service.dart'; import 'package:immich_mobile/domain/services/feature_message.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
const int targetVersion = 26; const int targetVersion = 27;
Future<void> migrateDatabaseIfNeeded(Drift drift) async { Future<void> migrateDatabaseIfNeeded(Drift drift, NativeSyncApi nativeSyncApi, PermissionApi permissionApi) async {
final int? storedVersion = Store.tryGet(StoreKey.version); final int? storedVersion = Store.tryGet(StoreKey.version);
final version = storedVersion ?? targetVersion; final version = storedVersion ?? targetVersion;
@ -34,6 +41,11 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift); await _migrateTo26(drift);
} }
if (version < 27 && !await _migrateTo27(drift, nativeSyncApi, permissionApi)) {
await Store.put(StoreKey.version, 26);
return;
}
if (storedVersion == null) { if (storedVersion == null) {
await FeatureMessageService(SettingsRepository.instance).markSeen(); await FeatureMessageService(SettingsRepository.instance).markSeen();
} }
@ -42,6 +54,72 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
return; return;
} }
Future<bool> _migrateTo27(Drift drift, NativeSyncApi nativeSyncApi, PermissionApi permissionApi) async {
if (!CurrentPlatform.isAndroid) {
return true;
}
try {
if (!await nativeSyncApi.hasMediaReadPermission()) {
return false;
}
final dates = <String, DateTime>{};
void addDates(Iterable<PlatformAsset> assets) {
for (final asset in assets) {
dates[asset.id] = tryFromSecondsSinceEpoch(asset.createdAt, isUtc: true) ?? DateTime.timestamp();
}
}
for (final album in await nativeSyncApi.getAlbums()) {
addDates(await nativeSyncApi.getAssetsForAlbum(album.id));
}
if (await permissionApi.hasManageMediaPermission()) {
final trashed = await nativeSyncApi.getTrashedAssets();
addDates(trashed.values.flattened);
}
await drift.transaction(() async {
final localDates = {
for (final row in await (drift.selectOnly(
drift.localAssetEntity,
)..addColumns([drift.localAssetEntity.id, drift.localAssetEntity.createdAt])).get())
row.read(drift.localAssetEntity.id)!: row.read(drift.localAssetEntity.createdAt)!,
};
final trashedDates = {
for (final row in await (drift.selectOnly(
drift.trashedLocalAssetEntity,
)..addColumns([drift.trashedLocalAssetEntity.id, drift.trashedLocalAssetEntity.createdAt])).get())
row.read(drift.trashedLocalAssetEntity.id)!: row.read(drift.trashedLocalAssetEntity.createdAt)!,
};
for (final chunk in dates.entries.slices(kDriftMaxChunk)) {
await drift.batch((batch) {
for (final entry in chunk) {
final localDate = localDates[entry.key];
if (localDate != null && !localDate.isAtSameMomentAs(entry.value)) {
batch.update(
drift.localAssetEntity,
LocalAssetEntityCompanion(createdAt: Value(entry.value)),
where: (row) => row.id.equals(entry.key),
);
}
final trashedDate = trashedDates[entry.key];
if (trashedDate != null && !trashedDate.isAtSameMomentAs(entry.value)) {
batch.update(
drift.trashedLocalAssetEntity,
TrashedLocalAssetEntityCompanion(createdAt: Value(entry.value)),
where: (row) => row.id.equals(entry.key),
);
}
}
});
}
});
return true;
} catch (_) {
return false;
}
}
Future<void> _migrateTo25() async { Future<void> _migrateTo25() async {
final accessToken = Store.tryGet(StoreKey.accessToken); final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken == null || accessToken.isEmpty) { if (accessToken == null || accessToken.isEmpty) {

View file

@ -105,6 +105,8 @@ class CloudIdResult {
@HostApi() @HostApi()
abstract class NativeSyncApi { abstract class NativeSyncApi {
bool hasMediaReadPermission();
@async @async
bool shouldFullSync(); bool shouldFullSync();

View file

@ -313,6 +313,7 @@ class MediumRepositoryContext {
TrashOrigin? source, TrashOrigin? source,
AssetType? type, AssetType? type,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt,
bool? isFavorite, bool? isFavorite,
}) async { }) async {
id ??= TestUtils.uuid(); id ??= TestUtils.uuid();
@ -328,6 +329,7 @@ class MediumRepositoryContext {
source: .new(source ?? TrashOrigin.remoteSync), source: .new(source ?? TrashOrigin.remoteSync),
isFavorite: .new(isFavorite ?? false), isFavorite: .new(isFavorite ?? false),
createdAt: .new(TestUtils.date(createdAt)), createdAt: .new(TestUtils.date(createdAt)),
updatedAt: .new(TestUtils.date(updatedAt)),
), ),
); );
} }

View file

@ -0,0 +1,167 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:mocktail/mocktail.dart';
import '../../medium/repository_context.dart';
import '../../service.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late MediumRepositoryContext ctx;
late MockNativeSyncApi nativeSyncApi;
late MockPermissionApi permissionApi;
setUpAll(() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
ctx = MediumRepositoryContext();
await StoreService.init(storeRepository: DriftStoreRepository(ctx.db), listenUpdates: false);
});
setUp(() async {
nativeSyncApi = MockNativeSyncApi();
permissionApi = MockPermissionApi();
await Store.clear();
await ctx.db.delete(ctx.db.localAssetEntity).go();
await ctx.db.delete(ctx.db.trashedLocalAssetEntity).go();
when(() => nativeSyncApi.hasMediaReadPermission()).thenAnswer((_) async => true);
when(() => permissionApi.hasManageMediaPermission()).thenAnswer((_) async => true);
when(
() => nativeSyncApi.getAlbums(),
).thenAnswer((_) async => [PlatformAlbum(id: 'album', name: 'album', isCloud: false, assetCount: 1)]);
when(() => nativeSyncApi.getAssetsForAlbum('album')).thenAnswer((_) async => []);
when(() => nativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
});
tearDownAll(() async {
debugDefaultTargetPlatformOverride = null;
await Store.clear();
await ctx.dispose();
});
test('heals dates from MediaStore', () async {
final wrongDate = DateTime.utc(2026);
final platformDate = DateTime.utc(2019, 3, 15, 10, 30);
await Store.put(StoreKey.version, 26);
await ctx.newLocalAsset(id: 'local', createdAt: wrongDate, updatedAt: platformDate);
await ctx.newTrashedLocalAsset(
id: 'trashed',
albumId: 'album',
createdAt: wrongDate,
updatedAt: platformDate,
source: TrashOrigin.localSync,
);
when(() => nativeSyncApi.getAssetsForAlbum('album')).thenAnswer((_) async => [_asset('local', platformDate)]);
when(() => nativeSyncApi.getTrashedAssets()).thenAnswer(
(_) async => {
'album': [_asset('trashed', platformDate)],
},
);
await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi);
final local = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle();
final trashed = await (ctx.db.select(
ctx.db.trashedLocalAssetEntity,
)..where((row) => row.id.equals('trashed'))).getSingle();
expect(local.createdAt, platformDate);
expect(trashed.createdAt, platformDate);
expect(Store.tryGet(StoreKey.version), 27);
});
test('keeps EXIF date without trash access', () async {
final modifiedAt = DateTime.utc(2025);
final takenAt = DateTime.utc(2026);
await Store.put(StoreKey.version, 26);
await ctx.newLocalAsset(id: 'exif', createdAt: takenAt, updatedAt: modifiedAt);
when(
() => nativeSyncApi.getAssetsForAlbum('album'),
).thenAnswer((_) async => [_asset('exif', takenAt, updatedAt: modifiedAt)]);
when(() => permissionApi.hasManageMediaPermission()).thenAnswer((_) async => false);
await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi);
final asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('exif'))).getSingle();
expect(asset.createdAt, takenAt);
expect(Store.tryGet(StoreKey.version), 27);
verifyNever(() => nativeSyncApi.getTrashedAssets());
});
test('retries after permission is granted', () async {
final wrongDate = DateTime.utc(2026);
final platformDate = DateTime.utc(2019);
await Store.put(StoreKey.version, 26);
await ctx.newLocalAsset(id: 'local', createdAt: wrongDate, updatedAt: platformDate);
when(() => nativeSyncApi.hasMediaReadPermission()).thenAnswer((_) async => false);
await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi);
expect(Store.tryGet(StoreKey.version), 26);
verifyNever(() => nativeSyncApi.getAlbums());
verifyNever(() => nativeSyncApi.getTrashedAssets());
var asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle();
expect(asset.createdAt, wrongDate);
when(() => nativeSyncApi.hasMediaReadPermission()).thenAnswer((_) async => true);
when(() => nativeSyncApi.getAssetsForAlbum('album')).thenAnswer((_) async => [_asset('local', platformDate)]);
await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi);
asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle();
expect(asset.createdAt, platformDate);
expect(Store.tryGet(StoreKey.version), 27);
});
test('keeps version 26 when MediaStore read fails', () async {
final wrongDate = DateTime.utc(2026);
await Store.put(StoreKey.version, 26);
await ctx.newLocalAsset(id: 'local', createdAt: wrongDate);
when(() => nativeSyncApi.getAlbums()).thenThrow(StateError('query failed'));
await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi);
final asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle();
expect(asset.createdAt, wrongDate);
expect(Store.tryGet(StoreKey.version), 26);
});
test('heals out-of-range date and completes migration', () async {
final wrongDate = DateTime.utc(2026);
final before = DateTime.timestamp().subtract(const Duration(seconds: 1));
await Store.put(StoreKey.version, 26);
await ctx.newLocalAsset(id: 'local', createdAt: wrongDate);
when(
() => nativeSyncApi.getAssetsForAlbum('album'),
).thenAnswer((_) async => [_asset('local', wrongDate, createdAtSeconds: 8640000000001)]);
await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi);
final after = DateTime.timestamp().add(const Duration(seconds: 1));
final asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle();
expect(asset.createdAt.isBefore(before), isFalse);
expect(asset.createdAt.isAfter(after), isFalse);
expect(Store.tryGet(StoreKey.version), 27);
});
}
class MockPermissionApi extends Mock implements PermissionApi {}
PlatformAsset _asset(String id, DateTime createdAt, {DateTime? updatedAt, int? createdAtSeconds}) => PlatformAsset(
id: id,
name: '$id.jpg',
type: 1,
createdAt: createdAtSeconds ?? createdAt.millisecondsSinceEpoch ~/ 1000,
updatedAt: (updatedAt ?? createdAt).millisecondsSinceEpoch ~/ 1000,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image,
);