Compare commits

..

7 Commits

Author SHA1 Message Date
Santo Shakil
b35fe5debf cover epoch dates in both local asset tables 2026-07-24 02:35:21 +06:00
Santo Shakil
f400d5eb13 fix local date migration for trash and epoch rows 2026-07-24 02:31:11 +06:00
Santo Shakil
8213723059 Merge remote-tracking branch 'origin/main' into fix/android-local-album-date 2026-07-24 02:15:13 +06:00
Santo Shakil
7095d5771f persist migration progress when the date backfill fails 2026-07-24 02:14:31 +06:00
Adam Gastineau
3af94a4805 fix(mobile): properly align iOS widget loading state (#30173) 2026-07-23 12:31:33 -07:00
Santo Shakil
5eff0bda03 Merge remote-tracking branch 'origin/main' into fix/android-local-album-date
# Conflicts:
#	mobile/lib/utils/migration.dart
2026-07-22 14:08:41 +06:00
Santo Shakil
215559d86a fix(mobile): show the real date for android local photos with no exif
photos with no exif date showed their copy-to-phone date in the "on this device" albums instead of the real date. fall back to the earlier of date_modified/date_added (matches the server + ios), plus a migration to fix the rows already saved wrong.
2026-06-18 20:48:24 +06:00
5 changed files with 187 additions and 21 deletions

View File

@@ -174,11 +174,16 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
else -> 0L
}
// Date taken is milliseconds since epoch, Date added is seconds since epoch
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
?: c.getLong(dateAddedColumn)
// Date modified is seconds since epoch
// Date taken is in ms; added/modified are in seconds, and modified can be 0 when unset.
// No-EXIF assets use the earliest positive added/modified date, or raw added if neither is positive.
val modifiedAt = c.getLong(dateModifiedColumn)
val addedAt = c.getLong(dateAddedColumn)
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
?: when {
modifiedAt <= 0 -> addedAt
addedAt <= 0 -> modifiedAt
else -> minOf(modifiedAt, addedAt)
}
val width = c.getInt(widthColumn).toLong()
val height = c.getInt(heightColumn).toLong()
// Duration is milliseconds

View File

@@ -18,15 +18,21 @@ struct ImmichWidgetView: View {
var body: some View {
if entry.image == nil {
VStack {
Image("LaunchImage")
.tintedWidgetImageModifier()
Text(entry.metadata.error?.errorDescription ?? "")
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding(16)
Image("LaunchImage")
.tintedWidgetImageModifier()
.overlay(alignment: .bottom) {
if let error = entry.metadata.error?.errorDescription {
Text(error)
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.fixedSize()
.alignmentGuide(.bottom) { dimensions in
// Place the text below the bottom of the image
dimensions[.top] - 8
}
}
}
} else {
ZStack(alignment: .leading) {
Color.clear.overlay(

View File

@@ -83,13 +83,7 @@ struct ImmichMemoryProvider: TimelineProvider {
return
}
let memories: [MemoryResult]
do {
memories = try await api.fetchMemory(for: Date.now)
} catch {
completion(ImageEntry.handleError(for: cacheKey))
return
}
let memories = try await api.fetchMemory(for: Date.now)
await withTaskGroup(of: ImageEntry?.self) { group in
var totalAssets = 0

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/models/store.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/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
@@ -20,7 +21,7 @@ import 'package:immich_mobile/infrastructure/repositories/settings.repository.da
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26;
const int targetVersion = 27;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int? storedVersion = Store.tryGet(StoreKey.version);
@@ -34,6 +35,11 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
if (version < 27 && !await _migrateTo27(drift)) {
await Store.put(StoreKey.version, 26);
return;
}
if (storedVersion == null) {
await FeatureMessageService(SettingsRepository.instance).markSeen();
}
@@ -42,6 +48,28 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
return;
}
Future<bool> _migrateTo27(Drift drift) async {
// DATE_ADDED can be later than DATE_MODIFIED after a file is copied.
if (!CurrentPlatform.isAndroid) {
return true;
}
try {
await drift.customStatement(
"UPDATE local_asset_entity SET created_at = updated_at "
"WHERE julianday(updated_at) > julianday('1970-01-01T00:00:00Z') "
"AND julianday(created_at) > julianday(updated_at)",
);
await drift.customStatement(
"UPDATE trashed_local_asset_entity SET created_at = updated_at "
"WHERE julianday(updated_at) > julianday('1970-01-01T00:00:00Z') "
"AND julianday(created_at) > julianday(updated_at)",
);
return true;
} catch (_) {
return false;
}
}
Future<void> _migrateTo25() async {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken == null || accessToken.isEmpty) {

View File

@@ -0,0 +1,133 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.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/store.repository.dart';
import 'package:immich_mobile/utils/migration.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Drift db;
late DriftStoreRepository storeRepository;
setUpAll(() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
storeRepository = DriftStoreRepository(db);
await StoreService.init(storeRepository: storeRepository, listenUpdates: false);
});
setUp(() async {
await Store.clear();
await db.delete(db.localAssetEntity).go();
await db.delete(db.trashedLocalAssetEntity).go();
await db.customStatement('DROP TRIGGER IF EXISTS fail_migration');
});
tearDownAll(() async {
debugDefaultTargetPlatformOverride = null;
await Store.clear();
await db.close();
});
test('stores version 26 when migration 27 fails', () async {
await Store.put(StoreKey.version, 25);
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: 'asset',
name: 'asset.jpg',
type: AssetType.image,
createdAt: drift.Value(DateTime(2026)),
updatedAt: drift.Value(DateTime(2025)),
),
);
await db.customStatement(
"CREATE TRIGGER fail_migration BEFORE UPDATE OF created_at ON local_asset_entity "
"BEGIN SELECT RAISE(FAIL, 'migration failed'); END",
);
await migrateDatabaseIfNeeded(db);
expect(await storeRepository.tryGet(StoreKey.version), 26);
});
test('fixes dates in active and trashed assets', () async {
final createdAt = DateTime(2026);
final updatedAt = DateTime(2025);
final epoch = DateTime.fromMillisecondsSinceEpoch(0);
await Store.put(StoreKey.version, 26);
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: 'local',
name: 'local.jpg',
type: AssetType.image,
createdAt: drift.Value(createdAt),
updatedAt: drift.Value(updatedAt),
),
);
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: 'epoch',
name: 'epoch.jpg',
type: AssetType.image,
createdAt: drift.Value(createdAt),
updatedAt: drift.Value(epoch),
),
);
await db
.into(db.trashedLocalAssetEntity)
.insert(
TrashedLocalAssetEntityCompanion.insert(
id: 'trashed',
albumId: 'album',
name: 'trashed.jpg',
type: AssetType.image,
createdAt: drift.Value(createdAt),
updatedAt: drift.Value(updatedAt),
source: TrashOrigin.localSync,
),
);
await db
.into(db.trashedLocalAssetEntity)
.insert(
TrashedLocalAssetEntityCompanion.insert(
id: 'trashed-epoch',
albumId: 'album',
name: 'trashed-epoch.jpg',
type: AssetType.image,
createdAt: drift.Value(createdAt),
updatedAt: drift.Value(epoch),
source: TrashOrigin.localSync,
),
);
await migrateDatabaseIfNeeded(db);
final local = await (db.select(db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle();
final unchanged = await (db.select(db.localAssetEntity)..where((row) => row.id.equals('epoch'))).getSingle();
final trashed = await (db.select(db.trashedLocalAssetEntity)..where((row) => row.id.equals('trashed'))).getSingle();
final trashedUnchanged = await (db.select(
db.trashedLocalAssetEntity,
)..where((row) => row.id.equals('trashed-epoch'))).getSingle();
expect(local.createdAt, updatedAt);
expect(unchanged.createdAt, createdAt);
expect(trashed.createdAt, updatedAt);
expect(trashedUnchanged.createdAt, createdAt);
expect(await storeRepository.tryGet(StoreKey.version), 27);
});
}