diff --git a/CHANGELOG.md b/CHANGELOG.md index f2147f0..c7babda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ upload needs a **higher `versionCode`** than every previous upload. ### Fixed +- **The search field told the truth everywhere except inside a library.** Opening the field from a + library left the scope on "this folder" — the hint read *Search this folder* and a folder toggle + sat beside it — while the search actually ran everywhere, because the view model has always known + that a library is not a folder and answered from the index instead. So the screen claimed a scope it + was not using, and offered a toggle whose two settings did the same thing. Switching on open now + asks whether there is a **path** rather than whether this is the root list — a library has none + either — and the toggle is shown only when there is a folder to choose about, because a control + that cannot change the outcome implies a choice the user does not have. Found by driving the app on + the phone rather than by reading it: the search itself was right, and it was the screen that was + wrong. + - **Searching needed a volume to be open first, and needed a second tap to be typed into.** Two papercuts in the feature added above, both found by using it. `BrowserContent` decided between the volume list and a folder listing on `path == null` alone, so on the "Storage" screen the @@ -146,6 +157,36 @@ upload needs a **higher `versionCode`** than every previous upload. ### Added +- **A video shows a frame of itself instead of a film-strip icon.** A phone holds more video than + anything else, and a column of identical icons says nothing about which clip is which. Images and + videos now take the same path — `EntryIcon` hands Coil the file, and for a video the request asks + for a frame — which cost exactly one dependency: `io.coil-kt.coil3:coil-video`, at the same version + as the Coil already in use, whose own POM adds nothing that Coil did not already bring (coil-core, + kotlin-stdlib, core-ktx). No ExoPlayer and no media3, and **no new permission**: the decoder is the + platform's own `MediaMetadataRetriever`. The APK audit still shows the same permissions, with no + `READ_MEDIA_VIDEO` among them. + + The decoder is registered **explicitly** in `FileManagerApplication`, which is where this nearly + went wrong. `coil-video` ships a `META-INF/services` entry that registers it on its own, and in a + debug build that works: both the entry and the class it names are in the APK. In the **release** + build R8 removes both, because a class mentioned only from a resource looks unused — so the feature + worked in every build that had been looked at and would have failed silently in the one users + install. Registering the factory against an `ImageLoader` makes it a code reference R8 cannot drop, + and the release APK was then re-run on the phone to confirm frames decode there too. + + Two details are decisions rather than defaults. The frame is taken **one second in**, because the + first frame of a recording is very often black or the middle of a fade-in, and a thumbnail of a + black rectangle is no more useful than the icon it replaced. And a decode that **fails** — an + unsupported codec, or a file only pretending to be a video — settles on the icon rather than + leaving an empty square, which the row remembers so it is not retried on every recomposition. + + Verified on the phone, which is the only place this could be verified: two clips made for the + purpose (`ffmpeg`, a testsrc2 pattern and SMPTE bars) were pushed into the demo folder first, and + both drew their own pattern as a thumbnail, next to a real 4.3 MB video from the device's own + storage. The demo library's three stand-in `.mp4`s are zero-filled and cannot decode at all, which + turned out to be the cheapest possible test of the other half: all three fell back to the icon. The + same four rows were then re-checked on the **release** APK, which is where the difference shows. + - **Libraries: the home screen now opens on what is on the device, not only on where it is.** The volume list was the first thing a new user saw, so "where are my photos?" started with knowing which folder they were in. The root screen now carries a **Libraries** section — Images, Videos, diff --git a/README.md b/README.md index 01de8e4..5652f75 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,17 @@ no advertising or tracking SDKs. ## Status -**M12 — duplicate finder: done.** The browser scans for files that hold the same bytes — size first, -then the head of every candidate, then the survivors in full — and shows what deleting the spare -copies would give back. Before it: **M11 — library views**, a **Libraries** section on the root -screen (Images, Videos, Audio, Documents, Archives and Recent) whose rows open ordinary listings of -those files, each carrying a count from the search index. And **M10 — store assets**: the app is -named **Plain Files**, the icon and feature graphic come from the app's own vectors, and -[docs/STORE_LISTING.md](docs/STORE_LISTING.md) holds length-checked listing copy. Screenshots exist -for 5 phone (1080×2160), 4 on a 7-inch tablet (1200×1920) and 4 on a 10-inch tablet (2560×1600) — -but they **predate M11**, so the home screen in them is the old volume list and they will need -re-shooting, which the library views make a privacy question as well as a cosmetic one +**M13 — video thumbnails: done.** A video row shows a frame of the video itself rather than a film +icon — the platform's own retriever, through Coil, with a frame taken a second in and the icon kept +as the fallback when a file cannot be decoded. Before it: **M12 — duplicate finder** (size, then the +head of every candidate, then the survivors in full), **M11 — library views** (a **Libraries** +section on the root screen — Images, Videos, Audio, Documents, Archives and Recent — whose rows open +ordinary listings of those files, each carrying a count from the search index), and **M10 — store +assets**: the app is named **Plain Files**, the icon and feature graphic come from the app's own +vectors, and [docs/STORE_LISTING.md](docs/STORE_LISTING.md) holds length-checked listing copy. +Screenshots exist for 5 phone (1080×2160), 4 on a 7-inch tablet (1200×1920) and 4 on a 10-inch +tablet (2560×1600) — but they **predate M11**, so the home screen in them is the old volume list and +they will need re-shooting, which the library views make a privacy question as well as a cosmetic one ([docs/STORE_LISTING.md](docs/STORE_LISTING.md)). ## Tech stack diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed262c1..69cbcd0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -178,6 +178,10 @@ dependencies { // Image loading for thumbnails and the built-in image viewer. implementation(libs.coil.compose) + // Video frames, for the same thumbnails: the platform's own retriever, decoding a frame out of + // the clip. It registers itself with Coil through a ServiceLoader entry, so this one line is + // the whole integration — no ImageLoader has to be assembled by hand for it to take effect. + implementation(libs.coil.video) // ZIP/TAR/GZ archive support. Pure Java — no native code, no NDK. implementation(libs.commons.compress) diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt b/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt index 031e4e5..60b84f5 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt @@ -1,6 +1,10 @@ package de.softwareapp_hb.filemanager import android.app.Application +import android.content.Context +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.video.VideoFrameDecoder import de.softwareapp_hb.filemanager.data.ApkInspector import de.softwareapp_hb.filemanager.data.ContentResolverIncomingSource import de.softwareapp_hb.filemanager.data.DeleteRequestFactory @@ -22,7 +26,7 @@ import de.softwareapp_hb.filemanager.service.ServiceOperationNotifier * are built once and handed to the one ViewModel that needs them. Nothing holds a reference * to an Activity, so none of this leaks. */ -class FileManagerApplication : Application() { +class FileManagerApplication : Application(), SingletonImageLoader.Factory { /** Where files live and which backend is in effect. */ lateinit var storage: StorageProvider @@ -62,4 +66,25 @@ class FileManagerApplication : Application() { apkInspector = PackageManagerApkInspector(this) searchIndex = SqliteSearchIndex(this) } + + /** + * The image loader every thumbnail and viewer goes through. + * + * Built here for one reason: **video frames**. Coil has no idea what to do with an `.mp4` until a + * decoder is registered for it, and `coil-video` supplies one that understands the platform's own + * `MediaMetadataRetriever`. + * + * The decoder is registered **explicitly** rather than left to `coil-video`'s `META-INF/services` + * entry, because that entry does not survive a release build: R8 removes both the service file and + * the class it names, since a class mentioned only from a resource looks unused. Checked against + * the release APK, where neither was present — which would have left video thumbnails working in + * debug and silently showing an icon in the build everyone installs. + * + * Only the decoder is configured. Coil's own cache sizes are left alone deliberately: they are + * sized from the device's heap, which a number written here would only be guessing at. + */ + override fun newImageLoader(context: Context): ImageLoader = + ImageLoader.Builder(context) + .components { add(VideoFrameDecoder.Factory()) } + .build() } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt index dc2ccfa..b9f0b37 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt @@ -1,5 +1,6 @@ package de.softwareapp_hb.filemanager.ui.browser +import android.content.Context import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -30,16 +31,23 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.video.videoFrameMillis import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileEntry @@ -62,19 +70,35 @@ private val GridThumbnailSize = 96.dp internal val GridCellMinSize = 144.dp /** - * The leading visual for an entry: a real thumbnail for an image we can actually read, - * otherwise the category icon. + * The leading visual for an entry: a real thumbnail for something we can actually read, otherwise + * the category icon. * - * Coil needs a filesystem path, so this only attempts a thumbnail in the all-files mode — - * a SAF path is relative to the granted tree and would silently fail. + * **A video is a picture too.** A phone holds more video than anything else, and a column of + * identical film-strip icons says nothing about which clip is which — so a video's thumbnail is a + * frame of the video itself, decoded by the platform's own retriever (through `coil-video`, which + * registers itself with Coil rather than needing an `ImageLoader` to be assembled by hand). + * + * Two limits are inherited rather than invented. Coil needs a filesystem path, so this only attempts + * a thumbnail in the all-files mode — a SAF path is relative to the granted tree and would fail + * silently. And a decode that fails (an unsupported codec, a file that is really something else) + * falls back to the icon rather than leaving an empty square. */ @Composable fun EntryIcon(entry: FileEntry, size: Dp) { - if (entry.kind == FileKind.IMAGE && entry.path.value.startsWith("/")) { + // A decode that has already failed is not retried on every recomposition: the row keeps showing + // what it settled on, which is the icon. + var failed by remember(entry.path.value) { mutableStateOf(false) } + + if (entry.hasThumbnail() && !failed) { + val context = LocalContext.current + // Remembered, because Coil keys its caches by the request: a new request object on every + // recomposition would be a new cache entry on every recomposition. + val model = remember(entry.path.value, entry.kind) { entry.thumbnailRequest(context) } AsyncImage( - model = File(entry.path.value), + model = model, contentDescription = null, contentScale = ContentScale.Crop, + onError = { failed = true }, modifier = Modifier .size(size) .clip(RoundedCornerShape(6.dp)), @@ -93,6 +117,34 @@ fun EntryIcon(entry: FileEntry, size: Dp) { } } +/** + * Whether a thumbnail is worth attempting: something with a picture in it, at a real path. + * + * `startsWith("/")` is this app's test for "a file the platform can open by path" — the same test the + * built-in viewers make, and the reason the SAF fallback cannot show a thumbnail at all. + */ +private fun FileEntry.hasThumbnail(): Boolean = + (kind == FileKind.IMAGE || kind == FileKind.VIDEO) && path.value.startsWith("/") + +/** + * What to hand Coil: the file itself for a picture, and for a video a request that asks for a frame. + * + * The frame is taken [VIDEO_FRAME_MILLIS] in rather than at zero, because the first frame of a + * recording is very often black or the middle of a fade-in, and a thumbnail of a black rectangle is + * no more useful than the icon it replaced. + */ +private fun FileEntry.thumbnailRequest(context: Context): Any { + val file = File(path.value) + if (kind != FileKind.VIDEO) return file + return ImageRequest.Builder(context) + .data(file) + .videoFrameMillis(VIDEO_FRAME_MILLIS) + .build() +} + +/** Where in a video its thumbnail is taken: far enough in to be past the usual fade-in. */ +private const val VIDEO_FRAME_MILLIS = 1_000L + /** The icon that stands for a file category. */ fun iconFor(kind: FileKind): ImageVector = when (kind) { FileKind.DIRECTORY -> Icons.Filled.Folder diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt index 35fe978..fe1ae92 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt @@ -357,15 +357,20 @@ fun BrowserScreen( query = state.query, searchOpen = searchOpen, searchScope = state.searchScope, + // The choice only exists when there is a folder to make it about. + scopeChoosable = state.path != null, canPaste = state.clipboard?.isEmpty == false, onUp = viewModel::navigateUp, onQuery = viewModel::setQuery, onSearchOpen = { open -> searchOpen = open - // With no folder open there is nothing for "this folder" to mean, so the - // scope is switched as the field opens: its hint then says what the search - // will actually do, instead of inviting a query that could never match. - if (open && state.isRootView && state.searchScope != SearchScope.EVERYWHERE) { + // With no folder open there is nothing for "this folder" to mean — which is + // true of the volume list *and* of a library, since a library is not a folder + // either. The scope is switched as the field opens so that its hint says what + // the search will actually do, instead of inviting a query that could never + // match. The view model makes the same assumption on its own account, so this + // is the UI agreeing with the answer rather than deciding it. + if (open && state.path == null && state.searchScope != SearchScope.EVERYWHERE) { viewModel.setSearchScope(SearchScope.EVERYWHERE) } }, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt index 9dd7ef7..6a6842c 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt @@ -47,6 +47,8 @@ fun BrowserTopBar( query: String, searchOpen: Boolean, searchScope: SearchScope, + /** Whether the scope is the user's to choose at all; see the note beside the toggle. */ + scopeChoosable: Boolean, canPaste: Boolean, onUp: () -> Unit, onQuery: (String) -> Unit, @@ -116,22 +118,27 @@ fun BrowserTopBar( // Which folder tree a search answers for is a property of the search, not of // the folder being browsed, so it lives with the field rather than in the // overflow menu — and while the field is open it is one tap from either - // answer to the other. - IconButton(onClick = onSearchScope) { - Icon( - imageVector = if (searchScope == SearchScope.EVERYWHERE) { - Icons.Filled.Public - } else { - Icons.Filled.Folder - }, - contentDescription = stringResource( - if (searchScope == SearchScope.EVERYWHERE) { - R.string.search_scope_folder + // answer to the other. It is absent when there is no folder to choose about + // (the volume list, or a library, neither of which is one): a control whose + // two settings do the same thing is worse than no control at all, because it + // implies a choice the user does not have. + if (scopeChoosable) { + IconButton(onClick = onSearchScope) { + Icon( + imageVector = if (searchScope == SearchScope.EVERYWHERE) { + Icons.Filled.Public } else { - R.string.search_scope_everywhere + Icons.Filled.Folder }, - ), - ) + contentDescription = stringResource( + if (searchScope == SearchScope.EVERYWHERE) { + R.string.search_scope_folder + } else { + R.string.search_scope_everywhere + }, + ), + ) + } } IconButton(onClick = { onSearchOpen(false); onQuery("") }) { Icon( diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt index 15c041e..30ee286 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt @@ -884,6 +884,21 @@ class BrowserViewModelTest { ) } + @Test + fun `a search from inside a library is answered everywhere, because a library is not a folder`() { + // The same argument the volume list gets. A library has no path of its own, so "this + // folder" cannot mean anything there, and a query typed into a field that hinted at one + // must still be answered. + storage.addFile(root.child("holiday.png"), "x") + val index = indexOf(indexed(root.child("holiday.png"), "holiday.png")) + + val model = viewModel(searchIndex = index) + model.openCategory(FileCategory.IMAGES) + model.setQuery("holiday") + + assertEquals(listOf("holiday.png"), model.state.value.entries.map { it.name }) + } + private fun indexed(path: StoragePath, name: String, size: Long = 1L) = IndexedFile( volume = root.value, path = path, diff --git a/docs/EDGE_CASE_TESTING.md b/docs/EDGE_CASE_TESTING.md index f08ce15..fdcc6c8 100644 --- a/docs/EDGE_CASE_TESTING.md +++ b/docs/EDGE_CASE_TESTING.md @@ -134,9 +134,34 @@ through the prompt, because the prompt was already covered in the M7 session. | The duplicate scan | **Correct.** Tapping *Find duplicates* in the overflow ran the scan and produced real groups: `DOC-20251121-WA0001.zip` and `Schwarzhofen.zip` at 10.5 MB each (identical content in `Android/media/…` and `Download/`), a PDF duplicated at 5.3 MB, another PDF present **three** times at 761.3 KB. Group headers read "2 copies · 10.5 MB" above "10.5 MB could be freed", each row carried its **folder path**, and the groups were ordered by reclaimable space | | Ticking a copy | **Correct.** The row highlighted and the bar action appeared as **"Delete 1 copy"** — the plural resource agrees with the count | | Closing the scan | **Correct.** `X` returned to the browser with nothing changed | +| Video thumbnails | **Correct.** Two clips made for the purpose (`ffmpeg`: a testsrc2 pattern, then SMPTE bars) were pushed into the demo folder and both drew **their own pattern** as the row's thumbnail — a frame from inside the file, not a poster and not the icon — beside a real 4.3 MB video from the device's own storage. The library's three zero-filled stand-in `.mp4`s cannot be decoded and **fell back to the film icon**, which exercised the failure path with material that happened to be lying around | +| The same rows on the **release** APK (R8) | **Correct, after a fix — and this is the one that mattered.** `coil-video` registers its decoder through a `META-INF/services` entry, and the release APK contained neither that file nor the class it named: frames would have decoded in debug and quietly fallen back to icons for anyone who installed the app. Registering the factory explicitly in `FileManagerApplication` fixed it, and the release build was installed and driven to the Videos library, where all four rows behaved as the debug ones had | +| The search field opened from a library | **Wrong, and fixed.** The hint read *Search this folder* and a folder toggle sat beside it, while the search in fact ran everywhere — the view model has always answered from the index when there is no folder. See the findings below | | Crashes | **None.** `logcat -b crash` contained no entry for the package across the whole session | -### Two findings worth keeping +### Four findings worth keeping + +- **A ServiceLoader entry is not a contract, and R8 is the difference between a debug build and the + one people install.** `coil-video` registers its decoder with Coil through a `META-INF/services` + file. In the debug APK both that file and the class it names are present, and video thumbnails + worked. In the **release** APK neither was: R8 removes a class that is mentioned only from a + resource, because by its reckoning nothing uses it, and the resource goes with it. The feature was + therefore working in every build that had been looked at and would have failed silently in the one + that ships — the worst possible shape for a bug, since it needs no code change to "fix" and would + have been reported as "video thumbnails don't work" against a build nobody had run. Registering the + decoder explicitly against an `ImageLoader` turns it into a code reference, and the release APK was + then installed and driven to confirm it. The lesson generalises: **audit the release build, not the + debug one**, and never trust a registration that happens by naming a class in a file. + +- **The search field lied about its own scope inside a library, and only a run could show it.** + Opening the field from a library left the scope on "this folder": the hint said *Search this + folder* and offered a folder/public toggle, while the search actually answered from the index over + every volume. The behaviour was right and the *screen* was wrong — the same shape of bug the + volume-list fix had, and the same root cause: the switch-on-open asked whether this was the root + list instead of whether a folder was open, and a library is not the root list but has no folder + either. The toggle is now shown only when there is a folder to choose about, because its two + settings did the same thing otherwise. Nothing in the suite could have caught this: it was a + mismatch between two correct pieces, which is what a device pass is for. - **The library views are device-wide, so they cannot be photographed on a real phone.** The `Images` list showed the owner's own files — names and thumbnails included — because a library diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fc125dd..a43c059 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -22,6 +22,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | M10 | Store assets | ✅ | Name decided: **Plain Files** (one string; `applicationId` already locked). `tools/make-store-assets.sh` renders the 512×512 **RGBA** icon and the 1024×500 **RGB** feature graphic from the app's own vectors, so the store icon *is* the launcher icon; `tools/make-store-screenshots.sh` normalises `adb` captures to Play's 2:1 cap per form factor (phone crop default 93/130 px, tablets 0). [STORE_LISTING](STORE_LISTING.md) carries length-checked copy (name 11/30, short 67/80, full 2730/4000), the permissions paragraph the `MANAGE_EXTERNAL_STORAGE` declaration requires, and the graphics table. The feature graphic's copy was verified to survive Play's centre-square crop by cropping the rendered PNG. **Screenshots captured:** 5 phone (1080×2160), 4 on a 7-inch tablet (1200×1920) and 4 on a 10-inch tablet (2560×1600), all 24-bit RGB, inside the 2:1 cap. The tablet sets came from AOSP AVDs. The normaliser refuses a capture whose app area is blank, which is how a pure-background capture was caught before it reached the listing | | M11 | Library views | ✅ | A **Libraries** section on the root screen — Images, Videos, Audio, Documents, Archives, Recent — each row carrying how many files the index knows of that kind, each opening an ordinary listing (thumbnails, sort, multi-select, details, the existing viewers) answered from the index instead of from a directory. `core/FileCategory` groups `FileKind`s and is asserted to leave `DIRECTORY`, `APK` and `OTHER` out deliberately; `SearchIndex` gained `byKind`/`recent`/`kindCounts`, and SQLite schema **v3** adds the derived `kind` column — the migration rebuilds the table *and* drops the volume stamps, because a stamp whose rows have gone would make the next search trust an empty index. A library obeys the indexed search's own two rules: the answer comes first with the walk behind it, and every row is confirmed against the filesystem before it is shown. Not included, and recorded in the CHANGELOG as decisions rather than omissions: Downloads and New files, and restoring a library across process death. **308 JVM tests** (19 added here) | | M12 | Duplicate finder | ✅ | ***Find duplicates*** **in the overflow.** Three passes, cheapest first: **size** (the index's `duplicateCandidates` query — one query and no reading at all), **the first 64 KB** of every candidate (a filter that may only rule files *out*), and the **whole file** to confirm (`domain/DuplicateFinder`, SHA-256, cancellable between files and between read buffers). Groups are ordered by reclaimable space; each header states the copies and what they cost; every row carries its folder, because identical names with no paths are unactionable; and **nothing is preselected** — the delete is the app's ordinary one, through the trash and the undo snackbar. A file that cannot be read is counted and reported rather than skipped, and the empty state says how many files were compared — or that the index has never run and there was nothing to compare. **323 JVM tests** (15 added). Verified on the Xiaomi phone against four genuine groups in its own storage; the delete was deliberately not pressed on a device that is not ours — see [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md) | +| M13 | Video thumbnails | ✅ | A video row shows **a frame of the video** instead of a film icon: images and videos take the same path through Coil, with the video request asking for a frame a second in. One dependency — `coil-video` at the Coil version already pinned, whose POM adds only coil-core/stdlib/core-ktx — the platform's own `MediaMetadataRetriever` behind it, **no new permission** (no `READ_MEDIA_VIDEO`), and the decoder registered **explicitly** against an `ImageLoader` in `FileManagerApplication` — because R8 strips `coil-video`'s ServiceLoader entry from a release build, which would otherwise have left frames decoding in debug and quietly falling back to icons for real users. A decode that fails settles on the icon rather than an empty square. Verified on the phone against two purpose-made clips and a real video of the device's own, with the demo library's stand-ins falling back as intended, and **re-verified on the release APK**. **324 JVM tests** | ## Key decisions diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index 25ccbfe..6b07a60 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -56,6 +56,15 @@ thumbnails have something to show), a handful of documents, sized stand-ins for audio so the storage-usage screen has something worth ranking, and a real ZIP so the extract action can be exercised. Every name and every pixel is fake by construction. +One consequence worth knowing before shooting the Videos library: the video stand-ins are +**zero-filled**, so they cannot be decoded and photograph as the fallback film icon rather than as a +frame. A screenshot that shows video thumbnails needs a real clip, which is one command: + +```bash +ffmpeg -f lavfi -i "testsrc2=duration=3:size=320x240:rate=15" -pix_fmt yuv420p clip.mp4 # synthetic, no camera needed +adb push clip.mp4 /sdcard/Download/PlainFiles-Demo/Video/ +``` + | Asset | Play's spec | Result | Source | |---|---|---|---| | App icon | 512×512, 32-bit PNG with alpha, ≤ 1 MB | `store-assets/icon-512.png` — 512×512, **RGBA**, ~5 KB | `tools/store-assets/icon.svg`, mirroring `ic_launcher_background.xml` + `ic_launcher_foreground.xml`. Background, paths and opacities are identical, so **the store icon is the launcher icon** rather than a lookalike that drifts. | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d65750e..a281a2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,11 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material" kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +# Decodes a frame out of a video, so a video row shows what is in the clip rather than a film icon. +# Same version as coil-compose on purpose — a decoder is part of Coil's pipeline, not a plugin beside +# it — and its POM adds nothing coil-compose does not already bring (coil-core, kotlin-stdlib, +# core-ktx). No ExoPlayer and no media3, which is what makes this acceptable for a thumbnail. +coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" } # Commons Compress's LZMA/LZMA2 support: the `.xz` compressor and the 7z format.