mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge 0d0096d553 into f9c05af45f
This commit is contained in:
commit
6fa1d8a132
8 changed files with 278 additions and 11 deletions
|
|
@ -113,7 +113,8 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
|
|||
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 ->
|
||||
if (res is AssetResult.ValidAsset) {
|
||||
result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package app.alextran.immich.sync
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
|
@ -10,6 +12,7 @@ import android.os.ext.SdkExtensions
|
|||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.database.getStringOrNull
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import com.bumptech.glide.Glide
|
||||
|
|
@ -108,6 +111,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
|||
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(
|
||||
volume: String,
|
||||
selection: String,
|
||||
|
|
@ -175,11 +184,12 @@ 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.
|
||||
// If EXIF date taken exists use it, else if modified is empty use added, else the earliest of the two.
|
||||
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 height = c.getInt(heightColumn).toLong()
|
||||
// Duration is milliseconds
|
||||
|
|
@ -314,13 +324,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
|||
val selection =
|
||||
"(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION"
|
||||
|
||||
getCursor(
|
||||
val cursor = getCursor(
|
||||
MediaStore.VOLUME_EXTERNAL,
|
||||
selection,
|
||||
MEDIA_SELECTION_ARGS,
|
||||
projection,
|
||||
"${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC"
|
||||
)?.use { cursor ->
|
||||
) ?: error("MediaStore album query failed")
|
||||
cursor.use {
|
||||
val bucketIdColumn =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_ID)
|
||||
val bucketNameColumn =
|
||||
|
|
@ -395,7 +406,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
|||
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 }
|
||||
.toList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||
init(with defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func hasMediaReadPermission() throws -> Bool { PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized }
|
||||
|
||||
@available(iOS 16, *)
|
||||
private func getChangeToken() -> PHPersistentChangeToken? {
|
||||
|
|
|
|||
|
|
@ -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/pages/common/splash_screen.page.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/asset_viewer/share_intent_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
|
|
@ -55,7 +57,7 @@ void main() async {
|
|||
await initApp();
|
||||
// Warm-up isolate pool for worker manager
|
||||
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()));
|
||||
} catch (error, stack) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.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/domain/models/config/app_config.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/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/local_asset.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/network.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/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/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 version = storedVersion ?? targetVersion;
|
||||
|
||||
|
|
@ -34,6 +41,11 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
|||
await _migrateTo26(drift);
|
||||
}
|
||||
|
||||
if (version < 27 && !await _migrateTo27(drift, nativeSyncApi, permissionApi)) {
|
||||
await Store.put(StoreKey.version, 26);
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedVersion == null) {
|
||||
await FeatureMessageService(SettingsRepository.instance).markSeen();
|
||||
}
|
||||
|
|
@ -42,6 +54,72 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
|||
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 {
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ class CloudIdResult {
|
|||
|
||||
@HostApi()
|
||||
abstract class NativeSyncApi {
|
||||
bool hasMediaReadPermission();
|
||||
|
||||
@async
|
||||
bool shouldFullSync();
|
||||
|
||||
|
|
|
|||
|
|
@ -313,6 +313,7 @@ class MediumRepositoryContext {
|
|||
TrashOrigin? source,
|
||||
AssetType? type,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
bool? isFavorite,
|
||||
}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
|
|
@ -328,6 +329,7 @@ class MediumRepositoryContext {
|
|||
source: .new(source ?? TrashOrigin.remoteSync),
|
||||
isFavorite: .new(isFavorite ?? false),
|
||||
createdAt: .new(TestUtils.date(createdAt)),
|
||||
updatedAt: .new(TestUtils.date(updatedAt)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
167
mobile/test/modules/utils/migration_test.dart
Normal file
167
mobile/test/modules/utils/migration_test.dart
Normal 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,
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue