From 5f26cfbbf3b1158cbd93feb1f291fb031acba49b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 16 Feb 2025 20:32:34 +0100 Subject: [PATCH] #1427 increased precision of file modified date to milliseconds --- CHANGELOG.md | 1 + .../channel/calls/MediaFetchBytesHandler.kt | 13 ++-- .../calls/fetchers/ThumbnailFetcher.kt | 4 +- .../streams/MediaStoreStreamHandler.kt | 5 +- .../thibault/aves/model/EntryFields.kt | 2 +- .../thibault/aves/model/SourceEntry.kt | 10 ++-- .../aves/model/provider/FileImageProvider.kt | 6 +- .../model/provider/MediaStoreImageProvider.kt | 60 ++++++++++++------- lib/image_providers/thumbnail_provider.dart | 10 ++-- lib/model/db/db_sqflite.dart | 4 +- lib/model/db/db_sqflite_upgrade.dart | 35 +++++++++++ lib/model/entry/cache.dart | 8 +-- lib/model/entry/entry.dart | 42 ++++++------- lib/model/entry/extensions/images.dart | 2 +- lib/model/entry/extensions/keys.dart | 2 +- lib/model/multipage.dart | 2 +- lib/model/source/collection_source.dart | 8 +-- lib/model/source/media_store_source.dart | 4 +- lib/services/app_service.dart | 2 +- lib/services/media/media_fetch_service.dart | 6 +- lib/services/media/media_store_service.dart | 2 +- lib/widgets/editor/image.dart | 2 +- lib/widgets/viewer/debug/debug_page.dart | 2 +- .../viewer/visual/entry_page_view.dart | 2 +- test/fake/media_store_service.dart | 15 ++--- 25 files changed, 153 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e4924c7..9ab21a917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. ### Changed +- increased precision of file modified date to milliseconds - upgraded Flutter to stable v3.29.0 ### Fixed diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt index 70daf5c04..73f2ab19b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt @@ -8,6 +8,7 @@ import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher +import deckers.thibault.aves.model.EntryFields import deckers.thibault.aves.utils.MimeTypes import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -34,11 +35,11 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler { } private suspend fun getThumbnail(call: MethodCall, result: MethodChannel.Result) { - val uri = call.argument("uri") - val mimeType = call.argument("mimeType") - val dateModifiedSecs = call.argument("dateModifiedSecs")?.toLong() - val rotationDegrees = call.argument("rotationDegrees") - val isFlipped = call.argument("isFlipped") + val uri = call.argument(EntryFields.URI) + val mimeType = call.argument(EntryFields.MIME_TYPE) + val dateModifiedMillis = call.argument(EntryFields.DATE_MODIFIED_MILLIS)?.toLong() + val rotationDegrees = call.argument(EntryFields.ROTATION_DEGREES) + val isFlipped = call.argument(EntryFields.IS_FLIPPED) val widthDip = call.argument("widthDip")?.toDouble() val heightDip = call.argument("heightDip")?.toDouble() val pageId = call.argument("pageId") @@ -55,7 +56,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler { context = context, uri = uri, mimeType = mimeType, - dateModifiedSecs = dateModifiedSecs ?: (Date().time / 1000), + dateModifiedMillis = dateModifiedMillis ?: (Date().time), rotationDegrees = rotationDegrees, isFlipped = isFlipped, width = (widthDip * density).roundToInt(), diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index 5d7c4e6b6..0af9fc5ed 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -30,7 +30,7 @@ class ThumbnailFetcher internal constructor( private val context: Context, uri: String, private val mimeType: String, - private val dateModifiedSecs: Long, + private val dateModifiedMillis: Long, private val rotationDegrees: Int, private val isFlipped: Boolean, width: Int?, @@ -119,7 +119,7 @@ class ThumbnailFetcher internal constructor( // add signature to ignore cache for images which got modified but kept the same URI var options = RequestOptions() .format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565) - .signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId")) + .signature(ObjectKey("$dateModifiedMillis-$rotationDegrees-$isFlipped-$width-$pageId")) .override(width, height) if (isVideo(mimeType)) { options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt index d87f6076f..23734762e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -19,12 +19,13 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E private lateinit var eventSink: EventSink private lateinit var handler: Handler - private var knownEntries: Map? = null + // knownEntries: map of contentId -> dateModifiedMillis + private var knownEntries: Map? = null private var directory: String? = null init { if (arguments is Map<*, *>) { - knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to it.value as Int? }?.toMap() + knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to (it.value as Number?)?.toLong() }?.toMap() directory = arguments["directory"] as String? } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/EntryFields.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/EntryFields.kt index 279881445..1dab12be1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/EntryFields.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/EntryFields.kt @@ -18,7 +18,7 @@ object EntryFields { const val IS_FLIPPED = "isFlipped" // boolean const val DATE_ADDED_SECS = "dateAddedSecs" // long - const val DATE_MODIFIED_SECS = "dateModifiedSecs" // long + const val DATE_MODIFIED_MILLIS = "dateModifiedMillis" // long const val SOURCE_DATE_TAKEN_MILLIS = "sourceDateTakenMillis" // long const val DURATION_MILLIS = "durationMillis" // long diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt index 56d95818f..69af47c16 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt @@ -42,7 +42,7 @@ class SourceEntry { private var sourceRotationDegrees: Int? = null private var sizeBytes: Long? = null private var dateAddedSecs: Long? = null - private var dateModifiedSecs: Long? = null + private var dateModifiedMillis: Long? = null private var sourceDateTakenMillis: Long? = null private var durationMillis: Long? = null @@ -65,16 +65,16 @@ class SourceEntry { sizeBytes = toLong(map[EntryFields.SIZE_BYTES]) title = map[EntryFields.TITLE] as String? dateAddedSecs = toLong(map[EntryFields.DATE_ADDED_SECS]) - dateModifiedSecs = toLong(map[EntryFields.DATE_MODIFIED_SECS]) + dateModifiedMillis = toLong(map[EntryFields.DATE_MODIFIED_MILLIS]) sourceDateTakenMillis = toLong(map[EntryFields.SOURCE_DATE_TAKEN_MILLIS]) durationMillis = toLong(map[EntryFields.DURATION_MILLIS]) } - fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) { + fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedMillis: Long) { this.path = path this.title = title this.sizeBytes = sizeBytes - this.dateModifiedSecs = dateModifiedSecs + this.dateModifiedMillis = dateModifiedMillis } fun toMap(): FieldMap { @@ -89,7 +89,7 @@ class SourceEntry { EntryFields.SIZE_BYTES to sizeBytes, EntryFields.TITLE to title, EntryFields.DATE_ADDED_SECS to dateAddedSecs, - EntryFields.DATE_MODIFIED_SECS to dateModifiedSecs, + EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis, EntryFields.SOURCE_DATE_TAKEN_MILLIS to sourceDateTakenMillis, EntryFields.DURATION_MILLIS to durationMillis, // only for map export diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt index a002183d2..8ab754e2c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt @@ -46,7 +46,7 @@ internal class FileImageProvider : ImageProvider() { path = path, title = file.name, sizeBytes = file.length(), - dateModifiedSecs = file.lastModified() / 1000, + dateModifiedMillis = file.lastModified(), ) } } catch (e: SecurityException) { @@ -91,7 +91,7 @@ internal class FileImageProvider : ImageProvider() { return hashMapOf( EntryFields.URI to Uri.fromFile(newFile).toString(), EntryFields.PATH to newFile.path, - EntryFields.DATE_MODIFIED_SECS to newFile.lastModified() / 1000, + EntryFields.DATE_MODIFIED_MILLIS to newFile.lastModified(), ) } @@ -99,7 +99,7 @@ internal class FileImageProvider : ImageProvider() { try { val file = File(path) if (file.exists()) { - newFields[EntryFields.DATE_MODIFIED_SECS] = file.lastModified() / 1000 + newFields[EntryFields.DATE_MODIFIED_MILLIS] = file.lastModified() newFields[EntryFields.SIZE_BYTES] = file.length() } callback.onSuccess(newFields) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index d635d62fb..7b1b7b190 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -51,14 +51,14 @@ import kotlin.coroutines.suspendCoroutine class MediaStoreImageProvider : ImageProvider() { fun fetchAll( context: Context, - knownEntries: Map, + knownEntries: Map, directory: String?, handleNewEntry: NewEntryHandler, ) { Log.d(LOG_TAG, "fetching all media store items for ${knownEntries.size} known entries, directory=$directory") - val isModified = fun(contentId: Long, dateModifiedSecs: Int): Boolean { + val isModified = fun(contentId: Long, dateModifiedMillis: Long): Boolean { val knownDate = knownEntries[contentId] - return knownDate == null || knownDate < dateModifiedSecs + return knownDate == null || knownDate < dateModifiedMillis } val handleNew: NewEntryHandler var selection: String? = null @@ -96,7 +96,7 @@ class MediaStoreImageProvider : ImageProvider() { var found = false val fetched = arrayListOf() val id = uri.tryParseId() - val alwaysValid: NewEntryChecker = fun(_: Long, _: Int): Boolean = true + val alwaysValid: NewEntryChecker = fun(_: Long, _: Long): Boolean = true val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) } if (id != null) { if (sourceMimeType == null || isImage(sourceMimeType)) { @@ -227,8 +227,8 @@ class MediaStoreImageProvider : ImageProvider() { val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE) val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) - val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) - val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) + val dateAddedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) + val dateModifiedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN) // image & video for API >=29, only for images for API <29 @@ -240,8 +240,8 @@ class MediaStoreImageProvider : ImageProvider() { while (cursor.moveToNext()) { val id = cursor.getLong(idColumn) - val dateModifiedSecs = cursor.getInt(dateModifiedColumn) - if (isValidEntry(id, dateModifiedSecs)) { + val dateModifiedMillis = cursor.getInt(dateModifiedSecsColumn) * 1000L + if (isValidEntry(id, dateModifiedMillis)) { // for multiple items, `contentUri` is the root without ID, // but for single items, `contentUri` already contains the ID val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, id) @@ -255,17 +255,18 @@ class MediaStoreImageProvider : ImageProvider() { if (mimeType == null) { Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type") } else { - var entryMap: FieldMap = hashMapOf( + val path = cursor.getString(pathColumn) + var entryFields: FieldMap = hashMapOf( EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT, EntryFields.URI to itemUri.toString(), - EntryFields.PATH to cursor.getString(pathColumn), + EntryFields.PATH to path, EntryFields.SOURCE_MIME_TYPE to mimeType, EntryFields.WIDTH to width, EntryFields.HEIGHT to height, EntryFields.SOURCE_ROTATION_DEGREES to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0, EntryFields.SIZE_BYTES to cursor.getLong(sizeColumn), - EntryFields.DATE_ADDED_SECS to cursor.getInt(dateAddedColumn), - EntryFields.DATE_MODIFIED_SECS to dateModifiedSecs, + EntryFields.DATE_ADDED_SECS to cursor.getInt(dateAddedSecsColumn), + EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis, EntryFields.SOURCE_DATE_TAKEN_MILLIS to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null, EntryFields.DURATION_MILLIS to durationMillis, // only for map export @@ -285,8 +286,8 @@ class MediaStoreImageProvider : ImageProvider() { if (outWidth > 0 && outHeight > 0) { width = outWidth height = outHeight - entryMap[EntryFields.WIDTH] = width - entryMap[EntryFields.HEIGHT] = height + entryFields[EntryFields.WIDTH] = width + entryFields[EntryFields.HEIGHT] = height } } } catch (e: IOException) { @@ -302,11 +303,13 @@ class MediaStoreImageProvider : ImageProvider() { // missing some attributes such as width, height, orientation. // Also, the reported size of raw images is inconsistent across devices // and Android versions (sometimes the raw size, sometimes the decoded size). - val entry = SourceEntry(entryMap).fillPreCatalogMetadata(context) - entryMap = entry.toMap() + val entry = SourceEntry(entryFields).fillPreCatalogMetadata(context) + entryFields = entry.toMap() } - handleNewEntry(entryMap) + getFileModifiedDateMillis(path)?.let { entryFields[EntryFields.DATE_MODIFIED_MILLIS] = it } + + handleNewEntry(entryFields) found = true } } @@ -823,18 +826,32 @@ class MediaStoreImageProvider : ImageProvider() { try { val cursor = context.contentResolver.query(uri, projection, null, null, null) if (cursor != null && cursor.moveToFirst()) { - cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } - cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) } + cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_MILLIS] = cursor.getInt(it) * 1000 } + cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields[EntryFields.SIZE_BYTES] = cursor.getLong(it) } cursor.close() } } catch (e: Exception) { callback.onFailure(e) return@scanFile } + getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it } callback.onSuccess(newFields) } } + // try to fetch the modified date from the file, + // as it is more precise than the one from the Media Store + private fun getFileModifiedDateMillis(path: String?): Long? { + if (path != null) { + try { + return File(path).lastModified() + } catch (securityException: SecurityException) { + // ignore + } + } + return null + } + private fun scanObsoletePath(context: Context, uri: Uri, path: String, mimeType: String) { val file = File(path) val delayMillis = 500L @@ -918,8 +935,9 @@ class MediaStoreImageProvider : ImageProvider() { EntryFields.PATH to path, ) cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields[EntryFields.DATE_ADDED_SECS] = cursor.getInt(it) } - cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_SECS] = cursor.getInt(it) } + cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_MILLIS] = cursor.getInt(it) * 1000 } cursor.close() + getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it } return newFields } } catch (e: Exception) { @@ -1030,4 +1048,4 @@ object MediaColumns { typealias NewEntryHandler = (entry: FieldMap) -> Unit -private typealias NewEntryChecker = (contentId: Long, dateModifiedSecs: Int) -> Boolean \ No newline at end of file +private typealias NewEntryChecker = (contentId: Long, dateModifiedMillis: Long) -> Boolean \ No newline at end of file diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index c9f868296..ee36a0a28 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -42,7 +42,7 @@ class ThumbnailProvider extends ImageProvider { pageId: pageId, rotationDegrees: key.rotationDegrees, isFlipped: key.isFlipped, - dateModifiedSecs: key.dateModifiedSecs, + dateModifiedMillis: key.dateModifiedMillis, extent: key.extent, taskKey: key, ); @@ -75,11 +75,11 @@ class ThumbnailProviderKey extends Equatable { final int? pageId; final int rotationDegrees; final bool isFlipped; - final int dateModifiedSecs; + final int dateModifiedMillis; final double extent; @override - List get props => [uri, pageId, dateModifiedSecs, extent]; + List get props => [uri, pageId, dateModifiedMillis, extent]; const ThumbnailProviderKey({ required this.uri, @@ -87,10 +87,10 @@ class ThumbnailProviderKey extends Equatable { required this.pageId, required this.rotationDegrees, required this.isFlipped, - required this.dateModifiedSecs, + required this.dateModifiedMillis, this.extent = 0, }); @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedMillis=$dateModifiedMillis, extent=$extent}'; } diff --git a/lib/model/db/db_sqflite.dart b/lib/model/db/db_sqflite.dart index 6fbf88fe8..409045db1 100644 --- a/lib/model/db/db_sqflite.dart +++ b/lib/model/db/db_sqflite.dart @@ -57,7 +57,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb { ', sizeBytes INTEGER' ', title TEXT' ', dateAddedSecs INTEGER DEFAULT (strftime(\'%s\',\'now\'))' - ', dateModifiedSecs INTEGER' + ', dateModifiedMillis INTEGER' ', sourceDateTakenMillis INTEGER' ', durationMillis INTEGER' ', trashed INTEGER DEFAULT 0' @@ -117,7 +117,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb { ')'); }, onUpgrade: LocalMediaDbUpgrader.upgradeDb, - version: 13, + version: 14, ); final maxIdRows = await _db.rawQuery('SELECT MAX(id) AS maxId FROM $entryTable'); diff --git a/lib/model/db/db_sqflite_upgrade.dart b/lib/model/db/db_sqflite_upgrade.dart index 0ce793b94..bbbac6da1 100644 --- a/lib/model/db/db_sqflite_upgrade.dart +++ b/lib/model/db/db_sqflite_upgrade.dart @@ -47,6 +47,8 @@ class LocalMediaDbUpgrader { await _upgradeFrom11(db); case 12: await _upgradeFrom12(db); + case 13: + await _upgradeFrom13(db); } oldVersion++; } @@ -444,4 +446,37 @@ class LocalMediaDbUpgrader { await db.execute('ALTER TABLE $newCoverTable RENAME TO $coverTable;'); }); } + + static Future _upgradeFrom13(Database db) async { + debugPrint('upgrading DB from v13'); + + // rename column 'dateModifiedSecs' to 'dateModifiedMillis' + await db.transaction((txn) async { + const newEntryTable = '${entryTable}TEMP'; + await db.execute('CREATE TABLE $newEntryTable(' + 'id INTEGER PRIMARY KEY' + ', contentId INTEGER' + ', uri TEXT' + ', path TEXT' + ', sourceMimeType TEXT' + ', width INTEGER' + ', height INTEGER' + ', sourceRotationDegrees INTEGER' + ', sizeBytes INTEGER' + ', title TEXT' + ', dateAddedSecs INTEGER DEFAULT (strftime(\'%s\',\'now\'))' + ', dateModifiedMillis INTEGER' + ', sourceDateTakenMillis INTEGER' + ', durationMillis INTEGER' + ', trashed INTEGER DEFAULT 0' + ', origin INTEGER DEFAULT 0' + ')'); + await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateAddedSecs,dateModifiedMillis,sourceDateTakenMillis,durationMillis,trashed,origin)' + ' SELECT contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateAddedSecs,dateModifiedSecs*1000,sourceDateTakenMillis,durationMillis,trashed,origin' + ' FROM $entryTable;'); + await db.execute('DROP TABLE $entryTable;'); + await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;'); + }); + + } } diff --git a/lib/model/entry/cache.dart b/lib/model/entry/cache.dart index c0ff892a6..ace829c2c 100644 --- a/lib/model/entry/cache.dart +++ b/lib/model/entry/cache.dart @@ -19,11 +19,11 @@ class EntryCache { static Future evict( String uri, String mimeType, - int? dateModifiedSecs, + int? dateModifiedMillis, int rotationDegrees, bool isFlipped, ) async { - debugPrint('Evict cached images for uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped'); + debugPrint('Evict cached images for uri=$uri, mimeType=$mimeType, dateModifiedMillis=$dateModifiedMillis, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped'); // TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them int? pageId; @@ -42,7 +42,7 @@ class EntryCache { uri: uri, mimeType: mimeType, pageId: pageId, - dateModifiedSecs: dateModifiedSecs ?? 0, + dateModifiedMillis: dateModifiedMillis ?? 0, rotationDegrees: rotationDegrees, isFlipped: isFlipped, )).evict(); @@ -53,7 +53,7 @@ class EntryCache { uri: uri, mimeType: mimeType, pageId: pageId, - dateModifiedSecs: dateModifiedSecs ?? 0, + dateModifiedMillis: dateModifiedMillis ?? 0, rotationDegrees: rotationDegrees, isFlipped: isFlipped, extent: extent, diff --git a/lib/model/entry/entry.dart b/lib/model/entry/entry.dart index f4888c7e7..b01f0e7d4 100644 --- a/lib/model/entry/entry.dart +++ b/lib/model/entry/entry.dart @@ -36,7 +36,7 @@ class AvesEntry with AvesEntryBase { int? contentId; final String sourceMimeType; int width, height, sourceRotationDegrees; - int? dateAddedSecs, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis; + int? dateAddedSecs, _dateModifiedMillis, sourceDateTakenMillis, _durationMillis; bool trashed; int origin; @@ -66,7 +66,7 @@ class AvesEntry with AvesEntryBase { required this.sizeBytes, required String? sourceTitle, required this.dateAddedSecs, - required int? dateModifiedSecs, + required int? dateModifiedMillis, required this.sourceDateTakenMillis, required int? durationMillis, required this.trashed, @@ -82,7 +82,7 @@ class AvesEntry with AvesEntryBase { } this.path = path; this.sourceTitle = sourceTitle; - this.dateModifiedSecs = dateModifiedSecs; + this.dateModifiedMillis = dateModifiedMillis; this.durationMillis = durationMillis; } @@ -93,7 +93,7 @@ class AvesEntry with AvesEntryBase { int? contentId, String? title, int? dateAddedSecs, - int? dateModifiedSecs, + int? dateModifiedMillis, int? origin, List? stackedEntries, }) { @@ -111,7 +111,7 @@ class AvesEntry with AvesEntryBase { sizeBytes: sizeBytes, sourceTitle: title ?? sourceTitle, dateAddedSecs: dateAddedSecs ?? this.dateAddedSecs, - dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, + dateModifiedMillis: dateModifiedMillis ?? this.dateModifiedMillis, sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis, trashed: trashed, @@ -140,7 +140,7 @@ class AvesEntry with AvesEntryBase { sizeBytes: map[EntryFields.sizeBytes] as int?, sourceTitle: map[EntryFields.title] as String?, dateAddedSecs: map[EntryFields.dateAddedSecs] as int?, - dateModifiedSecs: map[EntryFields.dateModifiedSecs] as int?, + dateModifiedMillis: map[EntryFields.dateModifiedMillis] as int?, sourceDateTakenMillis: map[EntryFields.sourceDateTakenMillis] as int?, durationMillis: map[EntryFields.durationMillis] as int?, trashed: (map[EntryFields.trashed] as int? ?? 0) != 0, @@ -162,7 +162,7 @@ class AvesEntry with AvesEntryBase { EntryFields.sizeBytes: sizeBytes, EntryFields.title: sourceTitle, EntryFields.dateAddedSecs: dateAddedSecs, - EntryFields.dateModifiedSecs: dateModifiedSecs, + EntryFields.dateModifiedMillis: dateModifiedMillis, EntryFields.sourceDateTakenMillis: sourceDateTakenMillis, EntryFields.durationMillis: durationMillis, EntryFields.trashed: trashed ? 1 : 0, @@ -180,7 +180,7 @@ class AvesEntry with AvesEntryBase { EntryFields.height: height, EntryFields.rotationDegrees: rotationDegrees, EntryFields.isFlipped: isFlipped, - EntryFields.dateModifiedSecs: dateModifiedSecs, + EntryFields.dateModifiedMillis: dateModifiedMillis, EntryFields.sizeBytes: sizeBytes, EntryFields.trashed: trashed, EntryFields.trashPath: trashDetails?.path, @@ -241,7 +241,7 @@ class AvesEntry with AvesEntryBase { DateTime? _bestDate; DateTime? get bestDate { - _bestDate ??= dateTimeFromMillis(_catalogDateMillis) ?? dateTimeFromMillis(sourceDateTakenMillis) ?? dateTimeFromMillis((dateModifiedSecs ?? 0) * 1000); + _bestDate ??= dateTimeFromMillis(_catalogDateMillis) ?? dateTimeFromMillis(sourceDateTakenMillis) ?? dateTimeFromMillis(dateModifiedMillis ?? 0); return _bestDate; } @@ -292,10 +292,10 @@ class AvesEntry with AvesEntryBase { _bestTitle = null; } - int? get dateModifiedSecs => _dateModifiedSecs; + int? get dateModifiedMillis => _dateModifiedMillis; - set dateModifiedSecs(int? dateModifiedSecs) { - _dateModifiedSecs = dateModifiedSecs; + set dateModifiedMillis(int? dateModifiedMillis) { + _dateModifiedMillis = dateModifiedMillis; _bestDate = null; } @@ -362,7 +362,7 @@ class AvesEntry with AvesEntryBase { set catalogMetadata(CatalogMetadata? newMetadata) { final oldMimeType = mimeType; - final oldDateModifiedSecs = dateModifiedSecs; + final oldDateModifiedMillis = dateModifiedMillis; final oldRotationDegrees = rotationDegrees; final oldIsFlipped = isFlipped; @@ -372,7 +372,7 @@ class AvesEntry with AvesEntryBase { _tags = null; metadataChangeNotifier.notify(); - _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + _onVisualFieldChanged(oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped); } void clearMetadata() { @@ -399,7 +399,7 @@ class AvesEntry with AvesEntryBase { Future applyNewFields(Map newFields, {required bool persist}) async { final oldMimeType = mimeType; - final oldDateModifiedSecs = this.dateModifiedSecs; + final oldDateModifiedMillis = this.dateModifiedMillis; final oldRotationDegrees = this.rotationDegrees; final oldIsFlipped = this.isFlipped; @@ -426,8 +426,8 @@ class AvesEntry with AvesEntryBase { final sizeBytes = newFields[EntryFields.sizeBytes]; if (sizeBytes is int) this.sizeBytes = sizeBytes; - final dateModifiedSecs = newFields[EntryFields.dateModifiedSecs]; - if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs; + final dateModifiedMillis = newFields[EntryFields.dateModifiedMillis]; + if (dateModifiedMillis is int) this.dateModifiedMillis = dateModifiedMillis; final rotationDegrees = newFields[EntryFields.rotationDegrees]; if (rotationDegrees is int) this.rotationDegrees = rotationDegrees; final isFlipped = newFields[EntryFields.isFlipped]; @@ -438,7 +438,7 @@ class AvesEntry with AvesEntryBase { if (catalogMetadata != null) await localMediaDb.saveCatalogMetadata({catalogMetadata!}); } - await _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + await _onVisualFieldChanged(oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped); metadataChangeNotifier.notify(); } @@ -479,12 +479,12 @@ class AvesEntry with AvesEntryBase { // when the MIME type or the image itself changed (e.g. after rotation) Future _onVisualFieldChanged( String oldMimeType, - int? oldDateModifiedSecs, + int? oldDateModifiedMillis, int oldRotationDegrees, bool oldIsFlipped, ) async { - if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { - await EntryCache.evict(uri, oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedMillis != dateModifiedMillis || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { + await EntryCache.evict(uri, oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped); visualChangeNotifier.notify(); } } diff --git a/lib/model/entry/extensions/images.dart b/lib/model/entry/extensions/images.dart index ee6a1c9aa..339017486 100644 --- a/lib/model/entry/extensions/images.dart +++ b/lib/model/entry/extensions/images.dart @@ -25,7 +25,7 @@ extension ExtraAvesEntryImages on AvesEntry { pageId: pageId, rotationDegrees: rotationDegrees, isFlipped: isFlipped, - dateModifiedSecs: dateModifiedSecs ?? -1, + dateModifiedMillis: dateModifiedMillis ?? -1, extent: requestExtent, ); } diff --git a/lib/model/entry/extensions/keys.dart b/lib/model/entry/extensions/keys.dart index 1208f4afe..d6eeafb3d 100644 --- a/lib/model/entry/extensions/keys.dart +++ b/lib/model/entry/extensions/keys.dart @@ -17,7 +17,7 @@ class EntryFields { static const isFlipped = 'isFlipped'; // boolean static const dateAddedSecs = 'dateAddedSecs'; // long - static const dateModifiedSecs = 'dateModifiedSecs'; // long + static const dateModifiedMillis = 'dateModifiedMillis'; // long static const sourceDateTakenMillis = 'sourceDateTakenMillis'; // long static const durationMillis = 'durationMillis'; // long diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index ceec4592b..af8a0d724 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -120,7 +120,7 @@ class MultiPageInfo { sizeBytes: mainEntry.sizeBytes, sourceTitle: mainEntry.sourceTitle, dateAddedSecs: mainEntry.dateAddedSecs, - dateModifiedSecs: mainEntry.dateModifiedSecs, + dateModifiedMillis: mainEntry.dateModifiedMillis, sourceDateTakenMillis: mainEntry.sourceDateTakenMillis, durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis, trashed: trashed, diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 220eac7d5..4ef958f47 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -245,10 +245,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place switch (key) { case EntryFields.contentId: entry.contentId = newValue as int?; - case EntryFields.dateModifiedSecs: - // `dateModifiedSecs` changes when moving entries to another directory, + case EntryFields.dateModifiedMillis: + // `dateModifiedMillis` changes when moving entries to another directory, // but it does not change when renaming the containing directory - entry.dateModifiedSecs = newValue as int?; + entry.dateModifiedMillis = newValue as int?; case EntryFields.path: entry.path = newValue as String?; case EntryFields.title: @@ -369,7 +369,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place // title can change when moved files are automatically renamed to avoid conflict title: newFields[EntryFields.title] as String?, dateAddedSecs: newFields[EntryFields.dateAddedSecs] as int?, - dateModifiedSecs: newFields[EntryFields.dateModifiedSecs] as int?, + dateModifiedMillis: newFields[EntryFields.dateModifiedMillis] as int?, origin: newFields[EntryFields.origin] as int?, )); } else { diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 0d2cf9b98..e0b2887f8 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -101,7 +101,7 @@ class MediaStoreSource extends CollectionSource { final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet(); debugPrint('$runtimeType load ${stopwatch.elapsed} check obsolete entries'); - final knownDateByContentId = Map.fromEntries(knownLiveEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs))); + final knownDateByContentId = Map.fromEntries(knownLiveEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedMillis))); final knownContentIds = knownDateByContentId.keys.toList(); final removedContentIds = (await mediaStoreService.checkObsoleteContentIds(knownContentIds)).toSet(); if (topEntries.isNotEmpty) { @@ -290,7 +290,7 @@ class MediaStoreSource extends CollectionSource { if (sourceEntry != null) { final existingEntry = allEntries.firstWhereOrNull((entry) => entry.contentId == contentId); // compare paths because some apps move files without updating their `last modified date` - if (existingEntry == null || (sourceEntry.dateModifiedSecs ?? 0) > (existingEntry.dateModifiedSecs ?? 0) || sourceEntry.path != existingEntry.path) { + if (existingEntry == null || (sourceEntry.dateModifiedMillis ?? 0) > (existingEntry.dateModifiedMillis ?? 0) || sourceEntry.path != existingEntry.path) { final newPath = sourceEntry.path; final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null; if (volume != null) { diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart index 7c1cb949e..78ffde0d0 100644 --- a/lib/services/app_service.dart +++ b/lib/services/app_service.dart @@ -225,7 +225,7 @@ class PlatformAppService implements AppService { pageId: coverEntry.pageId, rotationDegrees: coverEntry.rotationDegrees, isFlipped: coverEntry.isFlipped, - dateModifiedSecs: coverEntry.dateModifiedSecs, + dateModifiedMillis: coverEntry.dateModifiedMillis, extent: size, ); } diff --git a/lib/services/media/media_fetch_service.dart b/lib/services/media/media_fetch_service.dart index bdbcca44a..f1bb87eda 100644 --- a/lib/services/media/media_fetch_service.dart +++ b/lib/services/media/media_fetch_service.dart @@ -53,7 +53,7 @@ abstract class MediaFetchService { required int rotationDegrees, required int? pageId, required bool isFlipped, - required int? dateModifiedSecs, + required int? dateModifiedMillis, required double extent, Object? taskKey, int? priority, @@ -211,7 +211,7 @@ class PlatformMediaFetchService implements MediaFetchService { required int rotationDegrees, required int? pageId, required bool isFlipped, - required int? dateModifiedSecs, + required int? dateModifiedMillis, required double extent, Object? taskKey, int? priority, @@ -222,7 +222,7 @@ class PlatformMediaFetchService implements MediaFetchService { final result = await _platformBytes.invokeMethod('getThumbnail', { 'uri': uri, 'mimeType': mimeType, - 'dateModifiedSecs': dateModifiedSecs, + 'dateModifiedMillis': dateModifiedMillis, 'rotationDegrees': rotationDegrees, 'isFlipped': isFlipped, 'widthDip': extent, diff --git a/lib/services/media/media_store_service.dart b/lib/services/media/media_store_service.dart index cbc07ea80..c0e634867 100644 --- a/lib/services/media/media_store_service.dart +++ b/lib/services/media/media_store_service.dart @@ -14,7 +14,7 @@ abstract class MediaStoreService { Future getGeneration(); - // knownEntries: map of contentId -> dateModifiedSecs + // knownEntries: map of contentId -> dateModifiedMillis Stream getEntries(Map knownEntries, {String? directory}); // returns media URI diff --git a/lib/widgets/editor/image.dart b/lib/widgets/editor/image.dart index 35b886fe3..ce5836c84 100644 --- a/lib/widgets/editor/image.dart +++ b/lib/widgets/editor/image.dart @@ -109,7 +109,7 @@ class _EditorImageState extends State { final viewportSize = margin.deflateSize(constraints.biggest); final minScale = ScaleLevel(factor: ScaleLevel.scaleForContained(viewportSize, canvasSize)); return AvesMagnifier( - key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'), + key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'), controller: widget.magnifierController, viewportPadding: margin, contentSize: mediaSize, diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index ef7d1497a..2fca03183 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -98,7 +98,7 @@ class ViewerDebugPage extends StatelessWidget { info: { 'catalogDateMillis': toDateValue(entry.catalogDateMillis), 'dateAddedSecs': toDateValue(entry.dateAddedSecs, factor: 1000), - 'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000), + 'dateModifiedMillis': toDateValue(entry.dateModifiedMillis), 'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis), 'bestDate': '${entry.bestDate}', }, diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 0f23b68be..17edab6a0 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -398,7 +398,7 @@ class _EntryPageViewState extends State with TickerProviderStateM return AvesMagnifier( // key includes modified date to refresh when the image is modified by metadata (e.g. rotated) - key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'), + key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'), controller: controller ?? _magnifierController, contentSize: displaySize ?? entry.displaySize, allowOriginalScaleBeyondRange: !isWallpaperMode, diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index df070763b..0d1ecdab0 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -40,12 +40,13 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { static int get nextId => _lastId++; - static int get dateSecs => DateTime.now().millisecondsSinceEpoch ~/ 1000; + static int get dateMillis => DateTime.now().millisecondsSinceEpoch; static AvesEntry newImage(String album, String filenameWithoutExtension, {int? id, int? contentId}) { id ??= nextId; contentId ??= id; - final date = dateSecs; + final _dateMillis = dateMillis; + final _dateSecs = _dateMillis ~/ 1000; return AvesEntry( origin: EntryOrigins.mediaStoreContent, id: id, @@ -59,9 +60,9 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { sourceRotationDegrees: 0, sizeBytes: 42, sourceTitle: filenameWithoutExtension, - dateAddedSecs: date, - dateModifiedSecs: date, - sourceDateTakenMillis: date, + dateAddedSecs: _dateSecs, + dateModifiedMillis: _dateMillis, + sourceDateTakenMillis: _dateMillis, durationMillis: null, trashed: false, ); @@ -77,7 +78,7 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { EntryFields.uri: 'content://media/external/images/media/$newContentId', EntryFields.contentId: newContentId, EntryFields.path: entry.path!.replaceFirst(sourceAlbum, destinationAlbum), - EntryFields.dateModifiedSecs: FakeMediaStoreService.dateSecs, + EntryFields.dateModifiedMillis: FakeMediaStoreService.dateMillis, }, deleted: false, ); @@ -94,7 +95,7 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { EntryFields.uri: 'content://media/external/images/media/$newContentId', EntryFields.contentId: newContentId, EntryFields.path: entry.path!.replaceFirst(oldName, newName), - EntryFields.dateModifiedSecs: FakeMediaStoreService.dateSecs, + EntryFields.dateModifiedMillis: FakeMediaStoreService.dateMillis, }, deleted: false, );