From ae44cdb994094cb5934ebd4078ada3657fda0472 Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Fri, 16 Oct 2026 14:33:18 +0200 Subject: [PATCH] Preview files inside an archive, and search every volume from an index The archive preview could say what a zip held but not show any of it, and the search could only answer "is it here?" for the folder tree the user was standing in. Both are now answered. ArchiveEngine gains a single-entry reader: a ZIP the backend can seek jumps straight at the entry's data through the central directory, 7z scans its index, a streaming container decompresses past whatever is in front of the entry, and a compression wrapper has exactly one entry whose name is derived from the archive's own. extractEntry is rewritten on top of it, so there is one implementation of "reach this entry" rather than two that could disagree, and the now-dead extractFromZip and only= parameters are removed. A tap on a row now previews and the row's action extracts, which is what a row means everywhere else in this app. Text and markup are read into memory, so the commonest question - what does this readme say - writes nothing at all and works in both storage modes. Everything else needs a file a decoder can open, so the entry is copied into the app's own cache, one folder per preview and under its own name so the viewer's title bar does not read copy-0.png, and deleted when the preview closes with a non-recursive delete. The copy is a real path, so image, PDF and media previews work even in the SAF fallback, where the browsed files cannot be previewed at all. A copy has no file to save back to, so the text editor shows no Save and markup offers no editor; closing returns to the archive rather than out of it. SearchIndex is a small port over SQLite beside the trash index, inheriting that one's doctrine that an index is not the truth: a reader drops rows whose file has gone and forgets them, freshness is reported rather than assumed, and a volume's rows are replaced wholesale so an interrupted pass leaves the previous listing rather than half of this one. Timestamps are written only when a pass finishes, so a walk cut short by a new keystroke reads as older than it is, which is the safe direction to be wrong in. Names are matched with NameMatcher's semantics translated into LIKE - substring, anchored glob, and a literal % or _ escaped - because a search that quietly widens what was typed is what an index does badly. The search field gains a scope toggle, and every search everywhere carries the age of the index and a way to rebuild it, because a search that silently omits what arrived since the last walk is worse than one that spends a minute being sure. Rows are written in batches of 500 and the counter reported every 100, since a row reading "0 files" for the whole first second reads as nothing happening. Deliberately not a foreground service: a pass runs while the screen is up and stops when it is not. Verified on the API 35 phone rather than inferred: a .txt and a .png inside a real zip, both previewed from the archive list with their own names, the copy gone on close and the archive list restored behind it; then the first search building the index with a full walk, and a search after an app restart answering instantly and finding a file in Download/cline-zip while browsing Internal storage. Watching it caught two lies, both fixed here: the empty state claimed "Nothing matches" while the index was still being written, and two progress bars stacked on each other. 268 JVM tests, lint clean, release APK builds and installs. The store listing gains the archive preview and the scoped search and is re-measured at 2730/4000. --- CHANGELOG.md | 160 +++++ .../filemanager/FileManagerApplication.kt | 14 + .../filemanager/core/MimeTypes.kt | 17 + .../filemanager/data/ApkInspector.kt | 100 ++++ .../filemanager/data/SearchIndex.kt | 88 +++ .../filemanager/data/SqliteSearchIndex.kt | 234 ++++++++ .../filemanager/domain/ArchiveEngine.kt | 423 +++++++++++-- .../filemanager/domain/SearchIndexer.kt | 95 +++ .../filemanager/domain/Trash.kt | 91 ++- .../ui/browser/BrowserComponents.kt | 28 +- .../filemanager/ui/browser/BrowserContent.kt | 151 ++++- .../filemanager/ui/browser/BrowserScreen.kt | 133 ++++- .../filemanager/ui/browser/BrowserTopBar.kt | 55 +- .../filemanager/ui/browser/BrowserUiState.kt | 77 ++- .../ui/browser/BrowserViewModel.kt | 562 +++++++++++++++++- .../filemanager/ui/browser/DetailsSheet.kt | 36 +- .../filemanager/ui/browser/EntryIntents.kt | 41 +- .../filemanager/ui/browser/SelectionBar.kt | 39 +- .../filemanager/ui/trash/TrashScreen.kt | 109 +++- .../filemanager/ui/viewer/ApkInfoScreen.kt | 201 +++++++ .../ui/viewer/ArchivePreviewScreen.kt | 198 ++++++ .../ui/viewer/MarkupPreviewScreen.kt | 116 ++++ .../ui/viewer/MediaPlayerScreen.kt | 250 ++++++++ .../filemanager/ui/viewer/PdfViewerScreen.kt | 276 +++++++++ .../filemanager/ui/viewer/TextEditorScreen.kt | 16 +- app/src/main/res/values/strings.xml | 60 ++ .../filemanager/core/FileEntryTest.kt | 30 + .../filemanager/data/SqliteSearchIndexTest.kt | 161 +++++ .../filemanager/domain/ArchiveEngineTest.kt | 245 ++++++++ .../filemanager/domain/SearchIndexerTest.kt | 110 ++++ .../filemanager/domain/TrashTest.kt | 62 ++ .../domain/testutil/InMemorySearchIndex.kt | 54 ++ .../ui/browser/BrowserViewModelTest.kt | 396 +++++++++++- docs/ROADMAP.md | 2 +- docs/STORE_LISTING.md | 37 +- 35 files changed, 4519 insertions(+), 148 deletions(-) create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/ApkInspector.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/SearchIndex.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndex.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/domain/SearchIndexer.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ApkInfoScreen.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ArchivePreviewScreen.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/MarkupPreviewScreen.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/MediaPlayerScreen.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/PdfViewerScreen.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndexTest.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/domain/SearchIndexerTest.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemorySearchIndex.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c8adf02..fca1c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,40 @@ upload needs a **higher `versionCode`** than every previous upload. device where the user had never declined anything. Reworded so it is true whether access was declined or simply never granted. +- **The file list jumped back to the top whenever a viewer closed.** `BrowserScreen` hands the + whole screen over to the image viewer, the text editor, the usage view and the trash by + returning early, which takes the listing out of the composition and, with it, the + `LazyColumn`'s own scroll state — so exiting an image you had scrolled down to preview put + you back at the first row, every time. The list and grid states are now remembered by the + screen, above the point where the overlay takes over, and passed down to the listing. They + are reset when the *folder* changes and only then, so navigating into a folder still starts + at the top while coming back from a viewer does not. + +- **Once a selection was running, tapping a second file opened it instead of selecting it.** + Long-press started multi-select, but every tap still went to "open", so the only way to add + another item was a long-press per file — and on a phone that is a gesture most people never + try. A tap now toggles the item in or out of the selection whenever one is running, which + is what makes the batch delete the bar offers actually usable; a checkbox on every row (not + just the selected ones) says so before the attempt. Outside a selection the old behaviour is + untouched: tap opens on a phone, highlights in the two-pane layout, double tap opens there, + and double-tap-to-open is suppressed while selecting so it cannot fire instead of the toggle. + +- **Tapping a file the app could not render did nothing at all.** Video, audio, PDF, DOCX, + APK, archive — every one of them was silently inert, which made the app look broken and + made half of what a file manager is for unreachable. `BrowserViewModel.open` had always + parked those entries in `BrowserUiState.pendingOpen`, and `ui/browser/EntryIntents.kt` had + `viewIntent()` sitting ready to wrap the path in a `FileProvider` URI and fire + `ACTION_VIEW` — but nothing ever read the one or called the other. A repository-wide search + found no call site for either, and a history search showed both were introduced in the same + commit and never touched again: the wire between them was simply never run. `BrowserScreen` + now launches the chooser from the same one-shot `LaunchedEffect` pattern its neighbours + (`pendingDeliver`, `pendingDeleteRequest`) already used, and always clears the signal + afterwards so a chooser cannot reopen itself on the next recomposition. The codebase's own + documentation had claimed this worked for some time — `docs/ROADMAP.md` lists "a + `FileProvider` backs 'open with' and share" under M4, and the changelog said the app + "already hands files out through the system chooser" — while only *share* was ever wired. + Nothing caught it because no test touched `pendingOpen`; three do now. + - **The details sheet put untranslatable English on screen.** It rendered `FileKind.name` directly, so "Type" read `directory`, `archive`, `apk` — enum constants rather than labels, and impossible to translate. Every category now has a string resource and a @@ -84,6 +118,132 @@ upload needs a **higher `versionCode`** than every previous upload. ### Added +- **A file inside an archive can be read without extracting it, and search can answer for the + whole volume in one query instead of walking it.** Both were the obvious next question after + the archive preview and the in-folder search: the preview could say what a zip held but not + show any of it, and the search could only answer "is it here?" for the tree the user happened + to be standing in. + + - **Previewing inside an archive.** `ArchiveEngine` gained a single-entry reader — a ZIP the + backend can seek jumps straight at the entry's data through the central directory, 7z scans + its index, a streaming container decompresses past whatever is in front of the entry, and a + compression wrapper has exactly one entry whose name is derived from the archive's own — and + `extractEntry` was rewritten on top of it, so there is one implementation of "reach this + entry" rather than two that could disagree. A tap on a row now *previews* and the row's + action extracts, which is what a row means everywhere else in this app. Text and markup are + read into memory, so the commonest question ("what does this readme say?") writes nothing at + all and works in both storage modes; everything else needs a file a decoder can open, so the + entry is copied into the app's own cache — one folder per preview, under the entry's own name + so the viewer's title bar does not read `copy-0.png` — and deleted when the preview closes, + with a non-recursive delete so a mistake can fail to tidy up but cannot remove a tree. The + copy is a real path, so image, PDF and media previews work even in the SAF fallback, where + the *browsed* files cannot be previewed at all. Two limits are stated rather than hidden: a + known-big entry is refused before anything is read (64 MB), an unknown one is capped while + decompressing because a 1 MB `.gz` can inflate to a gigabyte, and an entry with no viewer + says so instead of failing. A copy has no file to save back to, so the text editor shows no + Save and markup offers no editor; closing a preview returns to the archive it came from + rather than throwing the user out of the zip. Verified on the API 35 phone: a `.txt` and a + `.png` inside a real zip, both previewed from the archive list, the copy deleted on close, + the archive list restored behind it. + - **Indexed search everywhere.** `SearchIndex` is a small port over SQLite beside the trash + index, and it inherits that one's doctrine — **an index is not the truth** — in three rules + that are not optional: a reader drops rows whose file has gone (every hit is checked and the + misses are forgotten, which is what stops a deleted photo appearing for ever), freshness is + reported and never assumed, and a volume's rows are replaced wholesale rather than patched, + so an interrupted pass leaves the previous listing rather than half of this one. Its + timestamps are written only when a pass *finishes*, so a walk that is cut short by a new + keystroke reads as older than it is — the safe direction to be wrong in. Names are matched + with `NameMatcher`'s own semantics translated into `LIKE`: a plain query is a substring, a + query with `*` or `?` is an anchored glob, and a literal `%` or `_` is escaped, because a + search that quietly widens what was typed is the sort of thing an index does badly. The + search row gained a scope toggle, and every search everywhere carries the age of the index + and a way to rebuild it — a search that silently omits what arrived since the last walk is + worse than one that spends a minute being sure, so a stale or missing index is rebuilt + rather than trusted. Rows are written in batches of 500 and the counter reports every 100, + because a row that says "0 files" for the whole first second reads as nothing happening. + Deliberately not a foreground service: a pass runs while the screen is up and stops when it + is not. Verified on the API 35 phone: the first search built the index (a full walk of the + internal storage), the second one — after an app restart — answered instantly from it and + found a file in `Download/cline-zip` while browsing `Internal storage`, with its full parent + path in the row, and a rebuild reported progress and re-answered the query when it finished. + Two honesty bugs found by watching it: the empty state claimed "Nothing matches" *while* the + index was still being written, and two progress bars stacked on each other. + +- **Six more kinds of file can be read in the app instead of guessed at.** Images and text had + a preview and nothing else did, so a zip, a PDF, a video, a song or an APK offered only + "hand it to another app" — and archives were the odd one out, because the app could *extract* + them all along and had no way to say what was in one. `ArchiveEngine` now lists an archive's + index without unpacking a byte of it, and takes a single row back out; five new screens cover + the rest: + + - **Archive** — the contents as an indented tree with each entry's uncompressed size, or a + plain statement that the archive stores none, which a `.gz`/`.bz2`/`.xz` wrapper never does. + Tapping a file writes just that file out beside the archive, keeping its path *from inside* + the archive so two `notes.txt` from different folders cannot land on each other, and the bar + keeps extract-all. The index comes straight from the central directory where the backend can + seek (ZIP, 7z) and is streamed, decompressing as it walks, where it cannot. + - **PDF** — pages drawn one at a time by `android.graphics.pdf.PdfRenderer`, off the main + thread, on a white canvas: a fresh bitmap is transparent, so black text on a dark theme + would read as a blank page. The pages are a pager, so a swipe turns one and the bar's + chevrons move the same position rather than a second one that could disagree with it, and + the page either side is drawn ahead so a turn reveals a page rather than a spinner. The + page bar pads itself for the navigation-bar inset: a bar in an edge-to-edge window has to + do that for itself, because a `Scaffold` hands its insets to the content and not to the + bars it is given — the selection bar gets away with it only because `BottomAppBar` does + it internally. Without it the bar sat under the system buttons and taps aimed at a + chevron could land on Back. + - **Audio and video** — the platform's own decoders, `VideoView` with its transport controls + for video and a card with a scrubber for audio, both released on the way out. + - **HTML and SVG** — the platform web engine, with scripting off, network loads blocked and + file access disabled, because a preview has no business running anyone's code and this app's + promise is that nothing leaves the device. This also fixes a quiet bug: `.svg` is classified + as an image, so it went to a raster viewer with no SVG decoder and showed an empty frame. + - **APK** — label, package, version, `minSdk`/`targetSdk` and permissions, read from the + archive's own manifest by a new `ApkInspector` port: `PackageManager` behind an interface, + exactly as `DeleteRequestFactory` and `IncomingSource` keep a `Context` out of the ViewModel. + Nothing here installs anything. + + All of it is platform APIs plus Commons Compress, which the build already had — no new + dependency and no native code. Which preview a tap reaches is decided in + `BrowserViewModel.open` and covered by tests; the viewers need a device, so each was driven + on one. The one place this narrows rather than widens: a preview that needs a real file — + image, PDF, video, audio — is now refused in the SAF fallback with "This preview needs full + file access" rather than opened, because no descriptor can be opened for a path that merely + looks absolute. Archive contents and markup are the two that work in both modes, because both + arrive through the backend as a stream. + +- **A deliberate "Open with", and Share that takes more than one file.** Wiring the missing + tap was only half of it: an image or a text file still opens in this app's own viewer or + editor by design, so handing one to Gallery or a code editor needed a way to say so. There + is now an **Open with** action in the selection bar and in the details sheet, and both are + shown only for actual files — a folder is not something another app can be handed. Opening + is offered for a single item because `ACTION_VIEW` carries one URI; **Share** now works for + a whole selection and switches to `ACTION_SEND_MULTIPLE` with a list of URIs, which is what + a batch of photos is for — the old action was single-selection only, so a multi-file + selection had no share at all. `MimeTypes.forIntent` picks the type the batch carries: the + one they all share, or `*/*` when they differ, because sending a mixed batch as one of the + two would quietly hide every app that handles only the other. + + The relay also fails honestly now instead of silently. In the SAF fallback the app cannot + hand anything out at all — its paths are relative to the granted tree while still looking + absolute, so `FileProvider` would happily build a `content://` URI pointing at nothing — + and that is now a message rather than a broken chooser. `BrowserViewModel.openWith` gates on + the access mode, and `contentUriFor`'s doc comment no longer claims a path's shape reveals + it (it does not: the check it described would pass for `/notes.txt` under SAF). The two + launcher failures — nothing on the device can open it, and no URI could be built — surface + as snackbars too, so a file that cannot be opened says so instead of doing nothing. + +- **The trash names what is in it, per file, and can put a single item back.** Its rows were + one per *batch*, headed `1 item` — so a handful of files deleted one at a time produced a + column of "1 item" rows that said nothing about what was actually in there. A row is now + one file: its name, its size (or "Folder"), and the full path it will be restored to, with + a Restore action that puts that one item back. Batches are still visible as a timestamp + header grouping their rows, and a batch of several items keeps a "Restore all" action, so + the old "put back what I deleted at 14:20" is still a single tap. Making the row richer + needed no filesystem work: `TrashRecord` already carried name, type and size, so + `TrashedEntry` now carries them out of the index instead of a reader having to `stat` + every staged file. + - **A trash you can actually recover from, after the fact.** The staged-delete work left a hole: the undo handle lived in memory, so a delete from a previous session was unreachable even though the files were sitting right there on the volume — and because nothing on disk 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 94640c0..031e4e5 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt @@ -1,10 +1,14 @@ package de.softwareapp_hb.filemanager import android.app.Application +import de.softwareapp_hb.filemanager.data.ApkInspector import de.softwareapp_hb.filemanager.data.ContentResolverIncomingSource import de.softwareapp_hb.filemanager.data.DeleteRequestFactory import de.softwareapp_hb.filemanager.data.IncomingSource import de.softwareapp_hb.filemanager.data.MediaStoreDeleter +import de.softwareapp_hb.filemanager.data.PackageManagerApkInspector +import de.softwareapp_hb.filemanager.data.SearchIndex +import de.softwareapp_hb.filemanager.data.SqliteSearchIndex import de.softwareapp_hb.filemanager.data.SqliteTrashIndex import de.softwareapp_hb.filemanager.data.StorageProvider import de.softwareapp_hb.filemanager.data.TrashIndex @@ -40,6 +44,14 @@ class FileManagerApplication : Application() { lateinit var trashIndex: TrashIndex private set + /** Reads an APK's manifest, so a package can be previewed before it is installed. */ + lateinit var apkInspector: ApkInspector + private set + + /** Remembers what is on the volumes by name, so a search does not have to walk them. */ + lateinit var searchIndex: SearchIndex + private set + override fun onCreate() { super.onCreate() storage = StorageProvider(this) @@ -47,5 +59,7 @@ class FileManagerApplication : Application() { deleteRequests = MediaStoreDeleter(this).factory() incoming = ContentResolverIncomingSource(contentResolver) trashIndex = SqliteTrashIndex(this) + apkInspector = PackageManagerApkInspector(this) + searchIndex = SqliteSearchIndex(this) } } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/MimeTypes.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/MimeTypes.kt index 6c44a4c..ac4a909 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/core/MimeTypes.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/MimeTypes.kt @@ -12,6 +12,14 @@ object MimeTypes { const val BINARY = "application/octet-stream" const val DIRECTORY = "vnd.android.document/directory" + /** + * What an intent carries when the items in it have no type in common. + * + * A chooser needs *a* type. Sharing a photo and a PDF together has no honest single + * answer, and the wildcard at least lets the chooser appear instead of matching nothing. + */ + const val ANY = "*/*" + private val BY_EXTENSION = mapOf( // images "jpg" to "image/jpeg", @@ -90,4 +98,13 @@ object MimeTypes { /** MIME type for an entry — directories get the SAF directory type. */ fun forEntry(entry: FileEntry): String = if (entry.isDirectory) DIRECTORY else forName(entry.name) + + /** + * The MIME type for an intent carrying [entries]. + * + * The type they all share, or [ANY] when they differ — sending a mixed batch as one of + * the two would quietly hide every app that only handles the other. + */ + fun forIntent(entries: List): String = + entries.mapTo(mutableSetOf()) { forEntry(it) }.singleOrNull() ?: ANY } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/ApkInspector.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/ApkInspector.kt new file mode 100644 index 0000000..0f05138 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/ApkInspector.kt @@ -0,0 +1,100 @@ +package de.softwareapp_hb.filemanager.data + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * What an APK says about itself, read without installing it. + * + * All of it comes from the package's own manifest, so a preview can be shown — and a + * decision made — before anything is installed. [icon] is `null` when the platform could + * not load one, which happens for an archive whose resources are unusual or missing, and + * is not worth failing the whole preview over. + */ +data class ApkInfo( + val label: String, + val packageName: String, + val versionName: String?, + val versionCode: Long, + val minSdk: Int, + val targetSdk: Int, + val permissions: List, + val icon: Bitmap? = null, +) + +/** + * Reads an APK's manifest without installing it. + * + * A port rather than something the ViewModel calls directly, for the same reason + * [DeleteRequestFactory] and [IncomingSource] are ports: the ViewModel deliberately holds + * no `Context`, so everything platform-shaped sits behind an interface that a test can + * replace with a fake. + */ +fun interface ApkInspector { + suspend fun inspect(path: String): ApkInfo? + + companion object { + /** Nothing is inspected — what a test or a build without a `Context` gets. */ + val None: ApkInspector = ApkInspector { null } + } +} + +/** The real [ApkInspector]: `PackageManager` over the archive's own path. */ +class PackageManagerApkInspector(context: Context) : ApkInspector { + + private val app = context.applicationContext + + override suspend fun inspect(path: String): ApkInfo? = withContext(Dispatchers.IO) { + if (!File(path).isFile) return@withContext null + + val manager = app.packageManager + val info = manager.getPackageArchiveInfo(path, GET_PERMISSIONS) ?: return@withContext null + val application = info.applicationInfo ?: return@withContext null + + // `loadLabel` and `loadIcon` read the archive's own resources, but only when these + // two point at the archive — otherwise they answer for the installed app (or for + // nothing at all). + application.sourceDir = path + application.publicSourceDir = path + + ApkInfo( + label = runCatching { manager.getApplicationLabel(application).toString() } + .getOrDefault(File(path).name), + packageName = info.packageName, + versionName = info.versionName, + versionCode = info.versionCode(), + minSdk = application.minSdkVersion, + targetSdk = application.targetSdkVersion, + permissions = info.requestedPermissions?.sorted().orEmpty(), + icon = runCatching { manager.getApplicationIcon(application).toBitmap() }.getOrNull(), + ) + } + + /** + * Draw the icon onto a bitmap rather than casting it: a modern adaptive icon is an + * `AdaptiveIconDrawable`, not a `BitmapDrawable`, and the cast would simply fail. + */ + private fun android.graphics.drawable.Drawable.toBitmap(): Bitmap { + val size = (ICON_DP * app.resources.displayMetrics.density).toInt() + return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also { bitmap -> + setBounds(0, 0, size, size) + draw(Canvas(bitmap)) + } + } + + @Suppress("DEPRECATION") + private fun PackageInfo.versionCode(): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) longVersionCode else versionCode.toLong() + + private companion object { + const val GET_PERMISSIONS = PackageManager.GET_PERMISSIONS + const val ICON_DP = 48 + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/SearchIndex.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/SearchIndex.kt new file mode 100644 index 0000000..4b0deef --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/SearchIndex.kt @@ -0,0 +1,88 @@ +package de.softwareapp_hb.filemanager.data + +import de.softwareapp_hb.filemanager.core.StoragePath + +/** + * One file the index knows about. + * + * Only what a result list has to show and sort by: the name it was found under, whether it + * is a folder, and enough metadata to render a row without going back to the filesystem. + * [volume] is carried per row — as [TrashRecord] carries its batch — so a volume's rows can + * be replaced wholesale without a join. + */ +data class IndexedFile( + val volume: String, + val path: StoragePath, + val name: String, + val isDirectory: Boolean, + val sizeBytes: Long, + val modifiedMillis: Long, +) + +/** + * A by-name index of what is on the volumes, so a search does not have to walk them. + * + * **An index, not the truth.** The filesystem is the truth and this only points at it — the + * same doctrine as [TrashIndex], and for the same reason: a walk of a phone's storage takes + * tens of seconds, so what makes search instant is a copy of what was there, and a copy is + * always *as of* some moment. Three rules follow, and none of them are optional: + * + * - **A reader drops rows whose file has gone.** [find] returning a row is not a promise + * that the file is there; the caller checks, and [forget]s what it found missing. That is + * what stops a deleted photo from appearing in results for ever. + * - **Freshness is reported, never assumed.** [indexedAt] is the age of the stalest volume, + * and the UI shows it, because the alternative is presenting yesterday's listing as + * today's. + * - **A volume is replaced, not patched.** [beginVolume] … [endVolume] swaps a volume's rows + * wholesale, so an interrupted pass leaves the previous listing rather than half of this + * one. + */ +interface SearchIndex { + + /** Start a fresh pass for [volume], discarding what it held. */ + suspend fun beginVolume(volume: String) + + /** Add a batch of rows for the volume being indexed. */ + suspend fun add(rows: List) + + /** Finish the pass, recording when it happened. */ + suspend fun endVolume(volume: String, indexedAtMillis: Long) + + /** + * Rows whose name matches [query], at most [limit]. + * + * [query] is what the user typed — [de.softwareapp_hb.filemanager.core.NameMatcher]'s + * substring-and-glob syntax — so an implementation narrows however it can and finishes + * the match itself. + */ + suspend fun find(query: String, limit: Int): List + + /** Drop one row, because the file it pointed at has gone. */ + suspend fun forget(path: StoragePath) + + /** When the stalest volume was walked, or `null` when nothing has been indexed. */ + suspend fun indexedAt(): Long? + + /** Forget everything, for a rebuild from nothing. */ + suspend fun clear() + + /** + * No index: every [find] comes back empty and every walk is forgotten. What a build + * without a `Context` gets, and what the tests that do not care about search use. + */ + object None : SearchIndex { + override suspend fun beginVolume(volume: String) = Unit + + override suspend fun add(rows: List) = Unit + + override suspend fun endVolume(volume: String, indexedAtMillis: Long) = Unit + + override suspend fun find(query: String, limit: Int): List = emptyList() + + override suspend fun forget(path: StoragePath) = Unit + + override suspend fun indexedAt(): Long? = null + + override suspend fun clear() = Unit + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndex.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndex.kt new file mode 100644 index 0000000..efc365b --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndex.kt @@ -0,0 +1,234 @@ +package de.softwareapp_hb.filemanager.data + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import de.softwareapp_hb.filemanager.core.StoragePath +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The search index, on SQLite. + * + * A second small database beside [SqliteTrashIndex] rather than a second table inside it: + * the two have nothing to say to each other, and the trash index's discipline — its rows can + * never be rebuilt from the device, so a migration must never drop them — is worth keeping + * well away from an index that is rebuilt on demand by design. + * + * Its own [Helper.upgrade] therefore *may* drop and recreate the tables, which is the one + * place the two differ; the step still has to be written out rather than left to chance. + */ +class SqliteSearchIndex(context: Context) : SearchIndex { + + private val helper = Helper(context.applicationContext) + + override suspend fun beginVolume(volume: String) = withContext(Dispatchers.IO) { + // The previous timestamp is deliberately left alone: until the new pass has finished, + // what the index knows really is as old as it was, and saying otherwise would be the + // one lie an index must not tell. + helper.writableDatabase.delete(TABLE_FILES, "$COLUMN_VOLUME = ?", arrayOf(volume)) + Unit + } + + override suspend fun add(rows: List) = withContext(Dispatchers.IO) { + if (rows.isEmpty()) return@withContext + val db = helper.writableDatabase + db.beginTransaction() + try { + for (row in rows) { + db.insertWithOnConflict(TABLE_FILES, null, row.toValues(), SQLiteDatabase.CONFLICT_REPLACE) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + override suspend fun endVolume(volume: String, indexedAtMillis: Long) = withContext(Dispatchers.IO) { + helper.writableDatabase.insertWithOnConflict( + TABLE_VOLUMES, + null, + ContentValues().apply { + put(COLUMN_VOLUME, volume) + put(COLUMN_INDEXED_AT, indexedAtMillis) + }, + SQLiteDatabase.CONFLICT_REPLACE, + ) + Unit + } + + override suspend fun find(query: String, limit: Int): List = withContext(Dispatchers.IO) { + val (clause, args) = likeClause(query) + helper.readableDatabase.query( + TABLE_FILES, + null, + clause, + args, + null, + null, + "$COLUMN_NAME ASC, $COLUMN_PATH ASC", + limit.toString(), + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.toRow()) } } + } + + override suspend fun forget(path: StoragePath) = withContext(Dispatchers.IO) { + helper.writableDatabase.delete(TABLE_FILES, "$COLUMN_PATH = ?", arrayOf(path.value)) + Unit + } + + override suspend fun indexedAt(): Long? = withContext(Dispatchers.IO) { + // The *stalest* volume, because an answer is only as fresh as the worst of it. + helper.readableDatabase + .rawQuery("SELECT MIN($COLUMN_INDEXED_AT) FROM $TABLE_VOLUMES", null) + .use { cursor -> if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else null } + } + + override suspend fun clear() = withContext(Dispatchers.IO) { + val db = helper.writableDatabase + db.beginTransaction() + try { + db.delete(TABLE_FILES, null, null) + db.delete(TABLE_VOLUMES, null, null) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + Unit + } + + private fun IndexedFile.toValues(): ContentValues = ContentValues().apply { + put(COLUMN_PATH, path.value) + put(COLUMN_NAME, name) + put(COLUMN_IS_DIRECTORY, if (isDirectory) 1 else 0) + put(COLUMN_SIZE_BYTES, sizeBytes) + put(COLUMN_MODIFIED, modifiedMillis) + put(COLUMN_VOLUME, volume) + } + + private fun Cursor.toRow(): IndexedFile = IndexedFile( + volume = getString(getColumnIndexOrThrow(COLUMN_VOLUME)), + path = StoragePath.of(getString(getColumnIndexOrThrow(COLUMN_PATH))), + name = getString(getColumnIndexOrThrow(COLUMN_NAME)), + isDirectory = getInt(getColumnIndexOrThrow(COLUMN_IS_DIRECTORY)) != 0, + sizeBytes = getLong(getColumnIndexOrThrow(COLUMN_SIZE_BYTES)), + modifiedMillis = getLong(getColumnIndexOrThrow(COLUMN_MODIFIED)), + ) + + /** + * How [query] narrows the table, in [NameMatcher]'s own terms. + * + * A plain query is a case-insensitive substring test, which `LIKE '%q%'` is; a query with + * `*` or `?` is a glob over the *whole* name, which is the same pattern with `*` → `%` + * and `?` → `_` and no wrapping wildcards. Everything else is a literal, so `%`, `_` and + * the escape character itself are escaped — otherwise typing an underscore would match + * any character, which is the sort of thing a search that quietly lies does. + * + * SQLite folds case for ASCII only, where [NameMatcher] folds it for everything, so a + * name that differs in case outside ASCII could match a walk and not the index. Closing + * that would mean a folded column and a collation the rest of the app does not use, for a + * distinction nobody can type. + */ + private fun likeClause(query: String): Pair> { + val trimmed = query.trim() + val glob = trimmed.any { it == '*' || it == '?' } + + val pattern = buildString { + if (!glob) append('%') + for (character in trimmed) { + when { + glob && character == '*' -> append('%') + glob && character == '?' -> append('_') + character == '%' || character == '_' || character == LIKE_ESCAPE -> { + append(LIKE_ESCAPE).append(character) + } + + else -> append(character) + } + } + if (!glob) append('%') + } + + return "$COLUMN_NAME LIKE ? ESCAPE '$LIKE_ESCAPE'" to arrayOf(pattern) + } + + private class Helper(context: Context) : SQLiteOpenHelper( + context, + DATABASE_NAME, + null, + DATABASE_VERSION, + ) { + override fun onCreate(db: SQLiteDatabase) = create(db) + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + upgrade(db, oldVersion, newVersion) + } + + /** + * The tables' shape, and how that shape moves forward. + * + * `internal` rather than `private` so a test can drive `create` and `upgrade` against a + * throwaway database, exactly as the trash index's does. + */ + internal companion object { + const val DATABASE_NAME = "search-index.db" + const val DATABASE_VERSION = 1 + const val TABLE_FILES = "indexed_files" + const val TABLE_VOLUMES = "indexed_volumes" + const val COLUMN_PATH = "path" + const val COLUMN_NAME = "name" + const val COLUMN_IS_DIRECTORY = "is_directory" + const val COLUMN_SIZE_BYTES = "size_bytes" + const val COLUMN_MODIFIED = "modified" + const val COLUMN_VOLUME = "volume" + const val COLUMN_INDEXED_AT = "indexed_at" + + private const val LIKE_ESCAPE = '\\' + + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE $TABLE_FILES ( + $COLUMN_PATH TEXT NOT NULL PRIMARY KEY, + $COLUMN_NAME TEXT NOT NULL, + $COLUMN_IS_DIRECTORY INTEGER NOT NULL, + $COLUMN_SIZE_BYTES INTEGER NOT NULL, + $COLUMN_MODIFIED INTEGER NOT NULL, + $COLUMN_VOLUME TEXT NOT NULL + ) + """.trimIndent(), + ) + // A search is a scan over names or a prefix of one, and a phone's storage is + // hundreds of thousands of rows; without this every keystroke is a table scan. + db.execSQL("CREATE INDEX indexed_files_name ON $TABLE_FILES ($COLUMN_NAME)") + db.execSQL("CREATE INDEX indexed_files_volume ON $TABLE_FILES ($COLUMN_VOLUME)") + db.execSQL( + """ + CREATE TABLE $TABLE_VOLUMES ( + $COLUMN_VOLUME TEXT NOT NULL PRIMARY KEY, + $COLUMN_INDEXED_AT INTEGER NOT NULL + ) + """.trimIndent(), + ) + } + + /** + * Bring [db] from [oldVersion] up to [newVersion], one version at a time. + * + * Unlike the trash index, a step here *is* allowed to drop and recreate the tables: + * this index is a cache of what a walk found, and a walk can be repeated. What is not + * allowed is leaving the shape to drift — a bump with no step is a bug to be + * reported, which is why this ends in an `error` rather than guessing. + */ + fun upgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + for (version in oldVersion until newVersion) { + error( + "No migration from search-index schema version $version to ${version + 1}. " + + "The index can be rebuilt from the device, so dropping and recreating " + + "is a valid step here — write it out deliberately and bump DATABASE_VERSION.", + ) + } + } + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/ArchiveEngine.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/ArchiveEngine.kt index 25344cd..3e7847b 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/domain/ArchiveEngine.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/ArchiveEngine.kt @@ -4,24 +4,56 @@ import de.softwareapp_hb.filemanager.core.ArchiveFormat import de.softwareapp_hb.filemanager.core.ArchiveFormats import de.softwareapp_hb.filemanager.core.ArchivePath import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.data.StorageException import de.softwareapp_hb.filemanager.data.StorageSource import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive +import org.apache.commons.compress.archivers.ArchiveEntry as CompressEntry import org.apache.commons.compress.archivers.ArchiveInputStream import org.apache.commons.compress.archivers.sevenz.SevenZFile import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream +import org.apache.commons.compress.archivers.zip.ZipFile import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import org.apache.commons.compress.compressors.xz.XZCompressorInputStream import java.io.BufferedInputStream import java.io.BufferedOutputStream +import java.io.ByteArrayOutputStream +import java.io.File import java.io.InputStream +import java.io.OutputStream import java.nio.channels.SeekableByteChannel import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlin.coroutines.cancellation.CancellationException +/** + * One entry an archive's index lists. + * + * [sizeBytes] is the uncompressed size the index claims, or `null` when it does not say — + * a ZIP written to a stream may omit it, and a compression wrapper (`.gz`, `.bz2`, `.xz`) + * has no index at all. Reporting that honestly beats printing a confident `0 B`. + */ +data class ArchiveEntry( + /** The name exactly as the archive stores it, slashes and all (`docs/2026/notes.txt`). */ + val name: String, + val isDirectory: Boolean, + val sizeBytes: Long?, +) { + /** The last segment, which is what a listing shows next to the indent. */ + val leafName: String get() = name.trimEnd('/').substringAfterLast('/').ifEmpty { name } + + /** + * How deep the entry sits, so a listing can indent it. + * + * `docs/` is depth 0 and `docs/notes.txt` is depth 1 — a folder does not count itself, + * which is what makes the tree line up. A folder entry is stored either as `docs` or as + * `docs/`, so both are normalized by trimming the trailing slash first. + */ + val depth: Int get() = name.trimEnd('/').count { it == '/' } +} + /** * Creates and extracts archives, on top of the storage backends. * @@ -88,6 +120,62 @@ class ArchiveEngine(private val source: StorageSource) { } } + /** + * What the archive's index says it holds, in the order it lists it. + * + * Read-only, and as cheap as the format allows: ZIP and 7z keep an index, so their + * names come out without decompressing a byte of the data. The streaming formats have + * to walk their entries, and a compressed wrapper (`.gz`, `.bz2`, `.xz`, `.tar.gz` …) + * has no index at all, so those are read through — listing a 2 GB `.tar.gz` costs + * about what decompressing it costs, which is why the caller shows a spinner rather + * than pretending it is instant. + * + * Throws when the archive cannot be read at all; the caller turns that into a message. + */ + suspend fun list(archive: StoragePath): List { + val format = ArchiveFormats.formatOf(archive.name) + if (format == ArchiveFormat.UNKNOWN || format == ArchiveFormat.UNSUPPORTED) { + throw StorageException("This archive format is not supported") + } + + // A single-file wrapper holds exactly one entry, and its name follows from the + // archive's own name — so this needs no I/O whatsoever. + if (format in SINGLE_FILE_FORMATS) { + return listOf( + ArchiveEntry( + name = ArchiveFormats.innerName(archive.name, format), + isDirectory = false, + sizeBytes = null, + ), + ) + } + + if (format == ArchiveFormat.SEVEN_ZIP) { + val channel = source.openSeekable(archive) + ?: throw StorageException("7z needs a seekable file, which this storage mode cannot provide") + return channel.use { seekable -> + SevenZFile.builder().setSeekableByteChannel(seekable).get().use { seven -> seven.listing() } + } + } + + // ZIP keeps a central directory at the end, so a seekable read gets every name + // without touching the data. Only the all-files backend can seek; the SAF fallback + // streams instead, which works but decompresses as it walks. + if (format == ArchiveFormat.ZIP) { + source.openSeekable(archive)?.let { channel -> + return channel.use { seekable -> + ZipFile.builder().setSeekableByteChannel(seekable).get().use { zip -> + zip.entries.toList().map { it.toListing() } + } + } + } + } + + return source.openRead(archive).use { raw -> + BufferedInputStream(raw).use { input -> readStreamListing(format, input) } + } + } + /** * Extract [archive] into [destination]. * @@ -124,54 +212,23 @@ class ArchiveEngine(private val source: StorageSource) { } else { readSevenZip(channel, destination, state) } + } else if (format in SINGLE_FILE_FORMATS) { + source.openRead(archive).use { raw -> + readSingle( + decompressor(format, raw), + ArchiveFormats.innerName(archive.name, format), + destination, + state, + ) + } } else { source.openRead(archive).use { raw -> BufferedInputStream(raw).use { input -> - when (format) { - ArchiveFormat.ZIP -> readArchive(ZipArchiveInputStream(input), destination, state) - - ArchiveFormat.TAR -> readArchive(TarArchiveInputStream(input), destination, state) - - ArchiveFormat.TAR_GZIP -> readArchive( - TarArchiveInputStream(GzipCompressorInputStream(input)), - destination, - state, - ) - - ArchiveFormat.TAR_BZIP2 -> readArchive( - TarArchiveInputStream(BZip2CompressorInputStream(input)), - destination, - state, - ) - - ArchiveFormat.TAR_XZ -> readArchive( - TarArchiveInputStream(XZCompressorInputStream(input)), - destination, - state, - ) - - ArchiveFormat.GZIP -> readSingle( - GzipCompressorInputStream(input), - ArchiveFormats.innerName(archive.name, format), - destination, - state, - ) - - ArchiveFormat.BZIP2 -> readSingle( - BZip2CompressorInputStream(input), - ArchiveFormats.innerName(archive.name, format), - destination, - state, - ) - - ArchiveFormat.XZ -> readSingle( - XZCompressorInputStream(input), - ArchiveFormats.innerName(archive.name, format), - destination, - state, - ) - - else -> state.failure(archive, "This archive format is not supported") + val stream = archiveStream(format, input) + if (stream == null) { + state.failure(archive, "This archive format is not supported") + } else { + readArchive(stream, destination, state) } } } @@ -184,6 +241,215 @@ class ArchiveEngine(private val source: StorageSource) { return state.result() } + /** + * Extract exactly one [entry] of [archive] into [destination]. + * + * [entry] comes from [list] of the same archive, and only its name is used — checked + * through [ArchivePath.safeRelativePath] like every other entry name, because an + * archive's index is no more trustworthy than its data. + */ + suspend fun extractEntry( + archive: StoragePath, + entry: ArchiveEntry, + destination: StoragePath, + onProgress: (OperationProgress) -> Unit = {}, + ): OperationResult { + val format = ArchiveFormats.formatOf(archive.name) + val state = ArchiveState(OperationKind.EXTRACT, 1, onProgress) + + if (format == ArchiveFormat.UNKNOWN || format == ArchiveFormat.UNSUPPORTED) { + state.failure(archive, "This archive format is not supported") + return state.result() + } + + val relative = ArchivePath.safeRelativePath(entry.name) + if (relative == null) { + state.failure(archive, "Skipped an unsafe entry: ${entry.name}") + return state.result() + } + + try { + source.createDirectory(destination) + val target = destination.resolve(relative) + target.parent?.let { source.createDirectory(it) } + + val found = withEntry(archive, entry.name) { input -> + source.createFile(target) + source.openWrite(target).use { output -> copyBounded(input, output, Long.MAX_VALUE) } + } + + if (found) { + state.advance(target) + } else { + state.failure(archive, "This archive does not hold ${entry.name}") + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Exception) { + state.failure(archive, failure.message ?: "The entry could not be extracted") + } + return state.result() + } + + /** + * Read one entry's bytes, without extracting anything. + * + * `null` when the archive does not hold that entry, and a failure when it holds more + * than [limit] decompressed bytes — a compression wrapper can inflate a small file + * enormously, so a preview caps what it is willing to be handed rather than trusting + * the size the index claims. + */ + suspend fun readEntry( + archive: StoragePath, + entry: ArchiveEntry, + limit: Long = Long.MAX_VALUE, + ): ByteArray? { + val out = ByteArrayOutputStream() + val found = withEntry(archive, entry.name) { input -> + copyBounded(input, out, limit) + } + return if (found) out.toByteArray() else null + } + + /** + * Copy one entry straight into [file], a plain file outside any storage backend. + * + * Needed because a preview has to hand the platform something it can open a descriptor + * for, and the SAF fallback's backend can only write inside its granted tree. The app's + * own cache is writable by plain `java.io.File` in both modes, with no permission + * involved, which is what makes this work everywhere the archive itself can be read. + */ + suspend fun extractEntryToFile( + archive: StoragePath, + entry: ArchiveEntry, + file: File, + limit: Long = Long.MAX_VALUE, + ): Boolean { + file.parentFile?.mkdirs() + return withEntry(archive, entry.name) { input -> + file.outputStream().use { output -> copyBounded(input, output, limit) } + } + } + + /** + * Find the entry called [name] in [archive] and hand its bytes to [sink], exactly once. + * + * The one place that knows how to reach a named entry in every format: a ZIP the backend + * can seek reads straight out of its central directory, 7z walks its index, a streaming + * container steps past whatever sits in front of the entry — decompressing as it goes — + * and a compression wrapper has exactly one entry, whose name is derived from the + * archive's own. + * + * Nothing is written here: where the bytes end up is the caller's business, which is what + * lets one entry be previewed without being extracted anywhere. + * + * `false` when the archive does not hold that entry, the name is unsafe, or the format + * cannot be read at all. + */ + private suspend fun withEntry( + archive: StoragePath, + name: String, + sink: suspend (InputStream) -> Unit, + ): Boolean { + val format = ArchiveFormats.formatOf(archive.name) + if (format == ArchiveFormat.UNKNOWN || format == ArchiveFormat.UNSUPPORTED) return false + if (ArchivePath.safeRelativePath(name) == null) return false + + // A wrapper stores no name at all, so the only entry it holds is the one its own + // name implies. + if (format in SINGLE_FILE_FORMATS) { + if (name != ArchiveFormats.innerName(archive.name, format)) return false + return source.openRead(archive).use { raw -> + decompressor(format, raw).use { sink(it) } + true + } + } + + if (format == ArchiveFormat.SEVEN_ZIP) { + val channel = source.openSeekable(archive) ?: return false + return channel.use { seekable -> + SevenZFile.builder().setSeekableByteChannel(seekable).get().use { seven -> + var found = false + while (!found) { + val entry = seven.nextEntry ?: break + if (entry.name != name || entry.isDirectory) continue + + // 7z hands out no stream per entry, so the sink reads this entry's + // bytes through the reader directly. + sink(seven.currentEntryStream()) + found = true + } + found + } + } + } + + // ZIP with a channel jumps straight at the entry; everything else walks past what is + // in front of it, which on a compressed tar means decompressing as it goes. + if (format == ArchiveFormat.ZIP) { + source.openSeekable(archive)?.let { channel -> + return channel.use { seekable -> + ZipFile.builder().setSeekableByteChannel(seekable).get().use { zip -> + val zipEntry = zip.entries.toList().firstOrNull { it.name == name } + if (zipEntry == null || zipEntry.isDirectory) return@use false + zip.getInputStream(zipEntry).use { sink(it) } + true + } + } + } + } + + return source.openRead(archive).use { raw -> + BufferedInputStream(raw).use { input -> + val stream = archiveStream(format, input) + if (stream == null) { + false + } else { + stream.use { + var found = false + while (!found) { + val entry = it.nextEntry ?: break + if (entry.name != name || entry.isDirectory) continue + sink(it) + found = true + } + found + } + } + } + } + } + + /** A [SevenZFile]'s current entry as a stream, since the format offers no other way in. */ + private fun SevenZFile.currentEntryStream(): InputStream = object : InputStream() { + override fun read(): Int { + val single = ByteArray(1) + val read = read(single, 0, 1) + return if (read <= 0) -1 else single[0].toInt() and 0xFF + } + + override fun read(b: ByteArray, off: Int, len: Int): Int = this@currentEntryStream.read(b, off, len) + } + + /** + * Copy [input] to [output], refusing to hand out more than [limit] bytes. + * + * A compression wrapper can inflate a small file enormously — a 1 MB `.gz` can be a + * gigabyte of zeroes — so anything bounded by a limit has to stop at the limit rather + * than trust the size the index claims, or trust that there is one. + */ + private fun copyBounded(input: InputStream, output: OutputStream, limit: Long) { + val buffer = ByteArray(BUFFER_BYTES) + var written = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) break + written += read + if (written > limit) throw StorageException("The entry is larger than the limit") + output.write(buffer, 0, read) + } + } + private suspend fun readArchive( archive: ArchiveInputStream<*>, destination: StoragePath, @@ -289,6 +555,63 @@ class ArchiveEngine(private val source: StorageSource) { state.advance(target) } + /** + * The container reader for [format], or `null` when the format is not a container. + * + * A compressed tar is two layers and both are unwrapped here, so the caller reads a + * plain tar either way. The caller owns the returned stream and closes it. + */ + private fun archiveStream(format: ArchiveFormat, input: InputStream): ArchiveInputStream<*>? = + when (format) { + ArchiveFormat.ZIP -> ZipArchiveInputStream(input) + ArchiveFormat.TAR -> TarArchiveInputStream(input) + ArchiveFormat.TAR_GZIP -> TarArchiveInputStream(GzipCompressorInputStream(input)) + ArchiveFormat.TAR_BZIP2 -> TarArchiveInputStream(BZip2CompressorInputStream(input)) + ArchiveFormat.TAR_XZ -> TarArchiveInputStream(XZCompressorInputStream(input)) + else -> null + } + + /** The decompressor for a single-file wrapper. The caller owns and closes it. */ + private fun decompressor(format: ArchiveFormat, raw: InputStream): InputStream = when (format) { + ArchiveFormat.GZIP -> GzipCompressorInputStream(raw) + ArchiveFormat.BZIP2 -> BZip2CompressorInputStream(raw) + ArchiveFormat.XZ -> XZCompressorInputStream(raw) + else -> raw + } + + /** + * Walk a streaming container's entries, reading metadata only. + * + * Metadata only is the best a stream can do: the compressed bytes still have to be read + * through to reach the next header, which is why a ZIP only lands on this path when the + * backend cannot seek, and why a compressed tar always does. + */ + private fun readStreamListing(format: ArchiveFormat, input: InputStream): List { + val stream = archiveStream(format, input) + ?: throw StorageException("This archive format is not supported") + stream.use { + val entries = ArrayList() + while (true) { + val entry = it.nextEntry ?: break + entries += entry.toListing() + } + return entries + } + } + + /** A 7z index, which needs no decompression to read. */ + private fun SevenZFile.listing(): List { + val entries = ArrayList() + while (true) { + val entry = nextEntry ?: break + entries += ArchiveEntry(entry.name, entry.isDirectory, entry.size.takeIf { it >= 0 }) + } + return entries + } + + /** A container entry mapped onto the app's own listing shape. */ + private fun CompressEntry.toListing() = ArchiveEntry(name, isDirectory, size.takeIf { it >= 0 }) + /** Progress bookkeeping, matching the one in [FileOperations]. */ private class ArchiveState( private val kind: OperationKind, @@ -321,5 +644,15 @@ class ArchiveEngine(private val source: StorageSource) { companion object { private const val BUFFER_BYTES = 64 * 1024 + + /** + * The formats that hold exactly one file and store no name for it: the name inside + * has to be derived from the archive's own, see [ArchiveFormats.innerName]. + */ + private val SINGLE_FILE_FORMATS = setOf( + ArchiveFormat.GZIP, + ArchiveFormat.BZIP2, + ArchiveFormat.XZ, + ) } } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/SearchIndexer.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/SearchIndexer.kt new file mode 100644 index 0000000..2139187 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/SearchIndexer.kt @@ -0,0 +1,95 @@ +package de.softwareapp_hb.filemanager.domain + +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.data.IndexedFile +import de.softwareapp_hb.filemanager.data.SearchIndex +import de.softwareapp_hb.filemanager.data.StorageSource +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive + +/** + * Fills a [SearchIndex] by walking a volume. + * + * The walk is the same one the live search does — depth-first, hidden entries skipped unless + * the preference says otherwise — with two differences that only make sense in the + * background: it is not capped at a couple of hundred results, and it hands its rows over in + * batches rather than collecting a whole volume's listing in memory. + * + * Cancelling it is safe and leaves the index consistent: rows may have been added, but a + * volume's timestamp is only written once the entire pass has finished, so an interrupted + * walk leaves the old timestamp in place and reads as *older* than it is. That is the safe + * direction to be wrong in. + */ +class SearchIndexer( + private val source: StorageSource, + private val index: SearchIndex, + private val clock: () -> Long = System::currentTimeMillis, +) { + + /** Walk everything under [volume], replacing what the index knows about it. Returns rows. */ + suspend fun index( + volume: StoragePath, + showHidden: Boolean, + onProgress: (Int) -> Unit = {}, + ): Int { + val key = volume.value + index.beginVolume(key) + + val batch = ArrayList(BATCH) + var total = 0 + + suspend fun flush() { + if (batch.isEmpty()) return + index.add(batch.toList()) + batch.clear() + onProgress(total) + } + + suspend fun walk(directory: StoragePath) { + if (!currentCoroutineContext().isActive) return + for (child in source.list(directory)) { + if (!currentCoroutineContext().isActive) return + + // A hidden folder is skipped whole, exactly as the listing and the live search + // skip it: the index must not know more than the app is willing to show. + if (!showHidden && child.isHidden) continue + + batch += child.toRow(key) + total++ + if (batch.size >= BATCH) { + flush() + } else if (total % REPORT_EVERY == 0) { + // The counter says how far the pass has got. A callback per file would cost + // more than the walk, but one only per write leaves the row reading "0 + // files" for the whole first second of a volume. + onProgress(total) + } + + if (child.isDirectory) walk(child.path) + } + } + + walk(volume) + flush() + index.endVolume(key, clock()) + return total + } + + private fun FileEntry.toRow(volume: String) = IndexedFile( + volume = volume, + path = path, + name = name, + isDirectory = isDirectory, + sizeBytes = sizeBytes, + modifiedMillis = lastModifiedMillis, + ) + + private companion object { + /** Rows per write: one transaction per few hundred files rather than one per file. */ + const val BATCH = 500 + + /** How often the counter is reported while a batch is still filling up. */ + const val REPORT_EVERY = 100 + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/Trash.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/Trash.kt index 59833f4..33545b7 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/domain/Trash.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/Trash.kt @@ -9,8 +9,22 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlin.coroutines.cancellation.CancellationException -/** Where one trashed item came from, so it can be put back exactly where it was. */ -data class TrashedEntry(val original: StoragePath, val staged: StoragePath) +/** + * One trashed item: where it came from, where it is now, and what it is. + * + * The name, type and size travel with the entry so a trash screen can describe each item + * without going back to the filesystem — the index already knew all three, so a reader no + * longer has to `stat` every staged file just to label a row. + */ +data class TrashedEntry( + val original: StoragePath, + val staged: StoragePath, + /** The item's name when it was staged; defaults to the one carried by [original]. */ + val name: String = original.name, + val isDirectory: Boolean = false, + /** The item's own size, not a folder's recursive total. Zero when it could not be read. */ + val sizeBytes: Long = 0L, +) /** One staged delete: everything [Trash.stage] moved aside, and where it went. */ data class TrashBatch( @@ -95,7 +109,15 @@ class Trash( if (result.succeeded) { // The item keeps its own name inside its own slot, so this is exact. - staged += TrashedEntry(original = path, staged = slot.child(path.name)) + val stagedPath = slot.child(path.name) + val stat = source.stat(stagedPath) + staged += TrashedEntry( + original = path, + staged = stagedPath, + name = path.name, + isDirectory = stat?.isDirectory ?: false, + sizeBytes = stat?.sizeBytes ?: 0L, + ) } else { failures += result.failures // Nothing landed here, so do not leave an empty slot behind. @@ -123,15 +145,14 @@ class Trash( // restore anything. index.record( staged.map { entry -> - val stat = source.stat(entry.staged) TrashRecord( batchId = batch.name, batchFolder = batch, originalPath = entry.original, stagedPath = entry.staged, - name = entry.original.name, - isDirectory = stat?.isDirectory ?: false, - sizeBytes = stat?.sizeBytes ?: 0L, + name = entry.name, + isDirectory = entry.isDirectory, + sizeBytes = entry.sizeBytes, stagedAtMillis = stagedAt, ) }, @@ -159,17 +180,7 @@ class Trash( return OperationResult(OperationKind.MOVE, restored, failures, cancelled = true) } - val parent = entry.original.parent - val result = when { - parent == null || !source.exists(entry.staged) -> OperationResult( - OperationKind.MOVE, - 0, - listOf(OperationFailure(entry.original, "It is no longer in the trash")), - false, - ) - - else -> operations.move(listOf(entry.staged), parent, ConflictPolicy.KEEP_BOTH) - } + val result = restoreOne(operations, entry) if (result.succeeded) { restored++ @@ -186,6 +197,47 @@ class Trash( return OperationResult(OperationKind.MOVE, restored, failures, false) } + /** + * Put one staged item back, leaving the rest of [batch] in the trash. + * + * The batch is forgotten once this was the last of it still on disk. A row whose staged + * file has gone restores nothing and [restorableFrom] would skip it on the next read + * anyway, but dropping it here keeps the index from carrying a dead batch until the + * next sweep. + */ + suspend fun restore( + entry: TrashedEntry, + batch: TrashBatch, + onProgress: (OperationProgress) -> Unit = {}, + ): OperationResult { + val result = restoreOne(FileOperations(source), entry) + + if (result.succeeded && restorableFrom(source, index).none { it.folder == batch.folder }) { + index.forget(batch.folder.name) + } + + onProgress(OperationProgress(OperationKind.MOVE, result.completed, 1, entry.original)) + return result + } + + /** Move one staged item back under its original parent; the index is left to the caller. */ + private suspend fun restoreOne( + operations: FileOperations, + entry: TrashedEntry, + ): OperationResult { + val parent = entry.original.parent + return when { + parent == null || !source.exists(entry.staged) -> OperationResult( + OperationKind.MOVE, + 0, + listOf(OperationFailure(entry.original, "It is no longer in the trash")), + false, + ) + + else -> operations.move(listOf(entry.staged), parent, ConflictPolicy.KEEP_BOTH) + } + } + /** Remove batches older than [RETENTION_HOURS]. Returns how many were dropped. */ suspend fun purgeExpired(): Int { // Rows whose batch folder has gone are dead weight — the user emptied the trash by @@ -262,6 +314,9 @@ class Trash( group.entries += TrashedEntry( original = record.originalPath, staged = record.stagedPath, + name = record.name, + isDirectory = record.isDirectory, + sizeBytes = record.sizeBytes, ) } return grouped.values.map { TrashBatch(it.folder, it.entries, it.stagedAtMillis) } 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 dbdf223..cebd692 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 @@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -98,6 +99,12 @@ fun EntryRow( modifier: Modifier = Modifier, /** Set for a search hit, to say which folder below the search root it was found in. */ location: String? = null, + /** + * `true` while a selection is running, which shows a checkbox on every row — including + * the unselected ones, so it is obvious that tapping them adds to the selection rather + * than opening them. + */ + selectionMode: Boolean = false, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, @@ -128,10 +135,17 @@ fun EntryRow( overflow = TextOverflow.Ellipsis, ) }, - trailingContent = if (selected) { - { Icon(imageVector = Icons.Filled.Check, contentDescription = null) } - } else { - null + trailingContent = when { + // The row's own click handles the toggle, so the box is not clickable itself. + selectionMode -> { + { Checkbox(checked = selected, onCheckedChange = null) } + } + + selected -> { + { Icon(imageVector = Icons.Filled.Check, contentDescription = null) } + } + + else -> null }, ) } @@ -145,6 +159,8 @@ fun EntryGridCell( modifier: Modifier = Modifier, /** Set for a search hit, to say which folder below the search root it was found in. */ location: String? = null, + /** `true` while a selection is running; see [EntryRow.selectionMode]. */ + selectionMode: Boolean = false, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, @@ -169,6 +185,10 @@ fun EntryGridCell( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { + // The cell is small, so the box sits above the thumbnail rather than over it. + if (selectionMode) { + Checkbox(checked = selected, onCheckedChange = null) + } EntryIcon(entry = entry, size = 40.dp) Spacer(Modifier.height(8.dp)) Text( diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt index f024f14..558dd10 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt @@ -11,10 +11,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -24,11 +28,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.core.Timestamps import de.softwareapp_hb.filemanager.data.StorageAccessMode /** @@ -53,11 +59,15 @@ fun BrowserContent( onOpenEntry: (FileEntry) -> Unit, onFocusEntry: (FileEntry) -> Unit, onFocusRoot: (RootItem) -> Unit, - onLongPressEntry: (StoragePath) -> Unit, + onToggleSelection: (StoragePath) -> Unit, onOpenRoot: (StoragePath) -> Unit, onNavigate: (StoragePath) -> Unit, onGrantAllFiles: () -> Unit, onPickFolder: () -> Unit, + onRebuildIndex: () -> Unit, + /** Hoisted by the screen so the scroll position outlives a viewer taking over. */ + listState: LazyListState = rememberLazyListState(), + gridState: LazyGridState = rememberLazyGridState(), ) { BoxWithConstraints( modifier = Modifier @@ -82,7 +92,7 @@ fun BrowserContent( ) state.isRootView && wide -> Row(modifier = Modifier.fillMaxSize()) { - LazyColumn(modifier = Modifier.width(MasterPaneWidth)) { + LazyColumn(modifier = Modifier.width(MasterPaneWidth), state = listState) { items(items = state.roots, key = { it.path.value }) { root -> RootRow( item = root, @@ -101,7 +111,7 @@ fun BrowserContent( ) } - state.isRootView -> LazyColumn(modifier = Modifier.fillMaxSize()) { + state.isRootView -> LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) { items(items = state.roots, key = { it.path.value }) { root -> RootRow(item = root, onClick = { onOpenRoot(root.path) }) } @@ -112,11 +122,14 @@ fun BrowserContent( state = state, crumbLabel = crumbLabel, wide = true, + listState = listState, + gridState = gridState, onOpenEntry = onOpenEntry, onFocusEntry = onFocusEntry, - onLongPressEntry = onLongPressEntry, + onToggleSelection = onToggleSelection, onNavigate = onNavigate, onGrantAllFiles = onGrantAllFiles, + onRebuildIndex = onRebuildIndex, modifier = Modifier.width(MasterPaneWidth), ) VerticalDivider() @@ -132,15 +145,20 @@ fun BrowserContent( state = state, crumbLabel = crumbLabel, wide = false, + listState = listState, + gridState = gridState, onOpenEntry = onOpenEntry, onFocusEntry = onFocusEntry, - onLongPressEntry = onLongPressEntry, + onToggleSelection = onToggleSelection, onNavigate = onNavigate, onGrantAllFiles = onGrantAllFiles, + onRebuildIndex = onRebuildIndex, ) } - if (state.loading || state.searching) { + // One bar at a time: while the index is being built, the row above this one says what + // is happening and carries its own bar. + if (state.loading || (state.searching && !state.indexing)) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter)) } } @@ -152,11 +170,14 @@ private fun DirectoryView( state: BrowserUiState, crumbLabel: (StoragePath) -> String, wide: Boolean, + listState: LazyListState, + gridState: LazyGridState, onOpenEntry: (FileEntry) -> Unit, onFocusEntry: (FileEntry) -> Unit, - onLongPressEntry: (StoragePath) -> Unit, + onToggleSelection: (StoragePath) -> Unit, onNavigate: (StoragePath) -> Unit, onGrantAllFiles: () -> Unit, + onRebuildIndex: () -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier.fillMaxSize()) { @@ -182,7 +203,21 @@ private fun DirectoryView( } } - if (state.entries.isEmpty()) { + // Searching everywhere is answered by the index, so what the index is worth is part of + // the answer: its age is on screen, and the way to make it current is one tap away. + if (state.searchScope == SearchScope.EVERYWHERE) { + IndexStatusRow( + indexing = state.indexing, + indexedCount = state.indexedCount, + indexedAt = state.indexedAt, + onRebuild = onRebuildIndex, + ) + } + + // While the index is being built there is nothing to say about matches yet: claiming + // "nothing matches" about an index that is half-written would be a lie told for a + // second, and it is exactly the sort of lie this feature exists to avoid. + if (state.entries.isEmpty() && !state.indexing) { EmptyState( message = if (state.query.isBlank()) { stringResource(R.string.browser_empty_folder) @@ -194,21 +229,41 @@ private fun DirectoryView( return@Column } - // With two panes on screen a single tap only highlights — the second pane is the - // reward — so opening moves to a double tap. A single pane has nowhere to preview - // into, so there a tap still opens outright. + /* + * How a tap behaves depends on what the user is doing, not on the window size alone: + * + * - While a selection is running, a tap toggles the item in or out of it. This is the + * whole point of multi-select — long-press to start, then tap the rest — and without + * it a tap would open a file and throw the selection away. + * - Otherwise, with two panes on screen a tap only highlights (the second pane is the + * reward) and opening moves to a double tap; a single pane has nowhere to preview + * into, so there a tap opens outright. + */ + val selecting = state.isSelecting + when (state.viewMode) { - ViewMode.LIST -> LazyColumn(modifier = Modifier.fillMaxSize()) { + ViewMode.LIST -> LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + ) { items(items = state.entries, key = { it.path.value }) { entry -> val open: () -> Unit = { onOpenEntry(entry) } val highlight: () -> Unit = { onFocusEntry(entry) } + val toggle: () -> Unit = { onToggleSelection(entry.path) } EntryRow( entry = entry, selected = entry.isHighlighted(state), + selectionMode = selecting, location = entry.searchLocation(state.searchRoot), - onClick = if (wide) highlight else open, - onLongClick = { onLongPressEntry(entry.path) }, - onDoubleClick = if (wide) open else null, + onClick = when { + selecting -> toggle + wide -> highlight + else -> open + }, + onLongClick = toggle, + // A double tap would otherwise fire instead of onClick and open the + // entry mid-selection, which is exactly what must not happen here. + onDoubleClick = if (wide && !selecting) open else null, ) } } @@ -216,6 +271,7 @@ private fun DirectoryView( ViewMode.GRID -> LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 108.dp), modifier = Modifier.fillMaxSize(), + state = gridState, contentPadding = PaddingValues(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -223,13 +279,19 @@ private fun DirectoryView( items(items = state.entries, key = { it.path.value }) { entry -> val open: () -> Unit = { onOpenEntry(entry) } val highlight: () -> Unit = { onFocusEntry(entry) } + val toggle: () -> Unit = { onToggleSelection(entry.path) } EntryGridCell( entry = entry, selected = entry.isHighlighted(state), + selectionMode = selecting, location = entry.searchLocation(state.searchRoot), - onClick = if (wide) highlight else open, - onLongClick = { onLongPressEntry(entry.path) }, - onDoubleClick = if (wide) open else null, + onClick = when { + selecting -> toggle + wide -> highlight + else -> open + }, + onLongClick = toggle, + onDoubleClick = if (wide && !selecting) open else null, ) } } @@ -241,6 +303,59 @@ private fun DirectoryView( private fun FileEntry.isHighlighted(state: BrowserUiState): Boolean = path in state.selection || path == state.focus?.entry?.path +/** + * What the index is worth, while searching everywhere. + * + * The age is the honest part: an index is a copy of what was there when it was last built, + * and a search that hides that lies by omission. While a pass is running this says what it is + * doing and how far it has got instead — a minute of silence looks like nothing happening. + */ +@Composable +private fun IndexStatusRow( + indexing: Boolean, + indexedCount: Int, + indexedAt: Long?, + onRebuild: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = when { + indexing -> stringResource( + R.string.search_indexing, + pluralStringResource( + R.plurals.usage_file_count, + indexedCount, + indexedCount, + ), + ) + + indexedAt != null -> stringResource( + R.string.search_index_at, + Timestamps.format(indexedAt), + ) + + else -> stringResource(R.string.search_index_missing) + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + maxLines = 2, + ) + TextButton(onClick = onRebuild, enabled = !indexing) { + Text(stringResource(R.string.search_index_rebuild)) + } + } + + if (indexing) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } +} + /** * Where a search hit was found, relative to the folder the search started from. * 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 1d87aff..6d52f6a 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 @@ -14,7 +14,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close @@ -68,7 +70,12 @@ import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.ui.trash.TrashScreen import de.softwareapp_hb.filemanager.ui.usage.UsageScreen +import de.softwareapp_hb.filemanager.ui.viewer.ApkInfoScreen +import de.softwareapp_hb.filemanager.ui.viewer.ArchivePreviewScreen import de.softwareapp_hb.filemanager.ui.viewer.ImageViewerScreen +import de.softwareapp_hb.filemanager.ui.viewer.MarkupPreviewScreen +import de.softwareapp_hb.filemanager.ui.viewer.MediaPlayerScreen +import de.softwareapp_hb.filemanager.ui.viewer.PdfViewerScreen import de.softwareapp_hb.filemanager.ui.viewer.TextEditorScreen @OptIn(ExperimentalMaterial3Api::class) @@ -92,9 +99,47 @@ fun BrowserScreen( var searchOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) } + /* + * Hoisted above the overlay branch below, and deliberately so: a viewer, the editor, the + * usage screen and the trash all take over the whole screen by returning early, which + * takes the listing out of the composition and with it the `LazyColumn`'s own state. + * Remembering it here means the scroll position survives all of them. + */ + val listState = rememberLazyListState() + val gridState = rememberLazyGridState() + + // A different folder starts at the top. Keyed on the folder alone, so coming back from a + // viewer — which never changes the folder — keeps the user exactly where they were. + LaunchedEffect(state.path) { + listState.scrollToItem(0) + gridState.scrollToItem(0) + } + // Taken on once. A recomposition must not re-arm the bar the user just dismissed. LaunchedEffect(incoming) { viewModel.onIncomingRequest(incoming) } + /* + * Handing a file to another app, in one place. Only the screen has a Context, and both + * ways in — a tap on a file this app cannot render, and the explicit "Open with" / + * "Share" actions — must fail the same way: something on screen, never silence. + */ + val handOff: (Intent?) -> Unit = { intent -> + when (intent) { + null -> viewModel.openFailed(R.string.message_open_unavailable) + else -> runCatching { context.startActivity(Intent.createChooser(intent, null)) } + .onFailure { viewModel.openFailed(R.string.message_open_no_app) } + } + } + + // `consumeOpen` runs however the launch went, or the same entry would be handed out + // again on the next recomposition — a chooser reopening itself is hard to escape. + LaunchedEffect(state.pendingOpen) { + state.pendingOpen?.let { entry -> + handOff(viewIntent(context, entry)) + viewModel.consumeOpen() + } + } + // Handing a file back closes the screen: whoever asked for it is waiting on a result. LaunchedEffect(state.pendingDeliver) { state.pendingDeliver?.let { entry -> @@ -175,12 +220,62 @@ fun BrowserScreen( TextEditorScreen( entry = overlay.entry, content = overlay.content, + editable = overlay.editable, onSave = viewModel::saveText, onClose = viewModel::closeOverlay, ) return } + is Overlay.Archive -> { + ArchivePreviewScreen( + entry = overlay.entry, + entries = overlay.entries, + loading = overlay.loading, + onPreviewEntry = viewModel::previewArchiveEntry, + onExtractEntry = viewModel::extractPreviewEntry, + // Closed first: the extraction lands in a new folder beside the archive, and + // that folder is the thing to look at afterwards. + onExtractAll = { + viewModel.closeOverlay() + viewModel.extract(overlay.entry) + }, + onClose = viewModel::closeOverlay, + ) + return + } + + is Overlay.Pdf -> { + PdfViewerScreen(entry = overlay.entry, onClose = viewModel::closeOverlay) + return + } + + is Overlay.Media -> { + MediaPlayerScreen(entry = overlay.entry, onClose = viewModel::closeOverlay) + return + } + + is Overlay.Markup -> { + MarkupPreviewScreen( + entry = overlay.entry, + markup = overlay.markup, + editable = overlay.editable, + onEditText = { viewModel.editText(overlay.entry) }, + onClose = viewModel::closeOverlay, + ) + return + } + + is Overlay.Package -> { + ApkInfoScreen( + entry = overlay.entry, + info = overlay.info, + loading = overlay.loading, + onClose = viewModel::closeOverlay, + ) + return + } + is Overlay.Usage -> { UsageScreen( nodes = overlay.nodes, @@ -194,7 +289,8 @@ fun BrowserScreen( TrashScreen( batches = overlay.batches, loading = overlay.loading, - onRestore = viewModel::restoreFromTrash, + onRestore = { batch -> viewModel.restoreFromTrash(batch) }, + onRestoreEntry = { batch, entry -> viewModel.restoreFromTrash(batch, entry) }, onClose = viewModel::closeOverlay, ) return @@ -240,10 +336,20 @@ fun BrowserScreen( canNavigateUp = !state.isRootView, query = state.query, searchOpen = searchOpen, + searchScope = state.searchScope, canPaste = state.clipboard?.isEmpty == false, onUp = viewModel::navigateUp, onQuery = viewModel::setQuery, onSearchOpen = { searchOpen = it }, + onSearchScope = { + viewModel.setSearchScope( + if (state.searchScope == SearchScope.EVERYWHERE) { + SearchScope.FOLDER + } else { + SearchScope.EVERYWHERE + }, + ) + }, onSort = { showSort = true }, onPaste = viewModel::paste, onRefresh = viewModel::refresh, @@ -274,12 +380,11 @@ fun BrowserScreen( onExtract = { state.selectedEntries.singleOrNull()?.let(viewModel::extract) }, + onOpenWith = { + state.selectedEntries.singleOrNull()?.let(viewModel::openWith) + }, onShare = { - state.selectedEntries.singleOrNull()?.let { entry -> - shareIntent(context, entry)?.let { intent -> - runCatching { context.startActivity(Intent.createChooser(intent, null)) } - } - } + handOff(shareIntent(context, state.selectedEntries)) }, onSelectAll = viewModel::selectAll, onClear = viewModel::clearSelection, @@ -318,10 +423,12 @@ fun BrowserScreen( state = state, contentPadding = PaddingValues(0.dp), crumbLabel = crumbLabel, + listState = listState, + gridState = gridState, onOpenEntry = viewModel::open, onFocusEntry = viewModel::focus, onFocusRoot = viewModel::focusRoot, - onLongPressEntry = viewModel::toggleSelection, + onToggleSelection = viewModel::toggleSelection, onOpenRoot = viewModel::openDirectory, onNavigate = viewModel::openDirectory, onGrantAllFiles = { @@ -333,6 +440,7 @@ fun BrowserScreen( } }, onPickFolder = { treeLauncher.launch(null) }, + onRebuildIndex = viewModel::rebuildSearchIndex, ) } } @@ -351,7 +459,16 @@ fun BrowserScreen( } detailsFor?.let { entry -> - DetailsSheet(entry = entry, onDismiss = { detailsFor = null }) + DetailsSheet( + entry = entry, + onDismiss = { detailsFor = null }, + // Closed first: the chooser would otherwise come up behind a sheet the user has + // to dismiss before they can reach it. + onOpenWith = { + detailsFor = null + viewModel.openWith(entry) + }, + ) } if (showNewFolder) { 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 f834835..ef821fd 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 @@ -6,8 +6,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PieChart +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search @@ -39,10 +41,12 @@ fun BrowserTopBar( canNavigateUp: Boolean, query: String, searchOpen: Boolean, + searchScope: SearchScope, canPaste: Boolean, onUp: () -> Unit, onQuery: (String) -> Unit, onSearchOpen: (Boolean) -> Unit, + onSearchScope: () -> Unit, onSort: () -> Unit, onPaste: () -> Unit, onRefresh: () -> Unit, @@ -52,7 +56,12 @@ fun BrowserTopBar( var menuOpen by remember { mutableStateOf(false) } TopAppBar( - title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + // The field takes the bar while it is open, which is what search in an app bar + // means everywhere: the breadcrumbs below already say which folder this is, and a + // squeezed title next to a squeezed field was worse than either. + title = { + if (!searchOpen) Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, navigationIcon = { if (canNavigateUp) { IconButton(onClick = onUp) { @@ -68,10 +77,42 @@ fun BrowserTopBar( OutlinedTextField( value = query, onValueChange = onQuery, - placeholder = { Text(stringResource(R.string.browser_search_hint)) }, + // The hint says what the search will actually do, so the scope is legible + // without decoding the icon beside it. + placeholder = { + Text( + stringResource( + if (searchScope == SearchScope.EVERYWHERE) { + R.string.search_scope_everywhere + } else { + R.string.browser_search_hint + }, + ), + ) + }, singleLine = true, - modifier = Modifier.width(200.dp), + modifier = Modifier.width(SEARCH_FIELD_WIDTH), ) + // 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 + } else { + R.string.search_scope_everywhere + }, + ), + ) + } IconButton(onClick = { onSearchOpen(false); onQuery("") }) { Icon( imageVector = Icons.Filled.Close, @@ -127,3 +168,11 @@ fun BrowserTopBar( }, ) } + +/** + * How wide the search field may be. + * + * The whole app bar when the field is open, minus the two actions beside it: at 200 dp the + * hint fits on one line, and at anything narrower it wrapped and read as a broken bar. + */ +private val SEARCH_FIELD_WIDTH = 200.dp diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt index 43fae95..a7dc3ab 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt @@ -8,12 +8,26 @@ import de.softwareapp_hb.filemanager.core.ConflictPolicy import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.SortSpec import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.data.ApkInfo import de.softwareapp_hb.filemanager.data.StorageAccessMode +import de.softwareapp_hb.filemanager.domain.ArchiveEntry import de.softwareapp_hb.filemanager.domain.TrashBatch import de.softwareapp_hb.filemanager.domain.UsageNode enum class ViewMode { LIST, GRID } +/** + * Where a search looks. + * + * The distinction is not cosmetic: [FOLDER] walks the filesystem as it types, so it is always + * current and can only answer for one folder tree; [EVERYWHERE] asks the index, so it answers + * for every volume at once and is only as current as the index says it is. + */ +enum class SearchScope { + FOLDER, + EVERYWHERE, +} + /** What the user has cut or copied, waiting to be pasted somewhere. */ data class Clipboard(val paths: List, val cut: Boolean) { val isEmpty: Boolean get() = paths.isEmpty() @@ -95,7 +109,45 @@ sealed interface Overlay { data class Image(val entry: FileEntry) : Overlay - data class Text(val entry: FileEntry, val content: String) : Overlay + data class Text( + val entry: FileEntry, + val content: String, + /** + * `false` for a copy of something inside an archive: there is no file of its own to + * save back to, so the editor shows no Save rather than offering a button that would + * write somewhere the user never chose. + */ + val editable: Boolean = true, + ) : Overlay + + /** The contents of an archive, listed without unpacking any of it. */ + data class Archive( + val entry: FileEntry, + val entries: List = emptyList(), + val loading: Boolean = true, + ) : Overlay + + /** A PDF, drawn page by page from the file itself. */ + data class Pdf(val entry: FileEntry) : Overlay + + /** Audio or video, played from the file itself. */ + data class Media(val entry: FileEntry) : Overlay + + /** Markup (`.html`, `.svg`) drawn from text the backend supplied. */ + data class Markup( + val entry: FileEntry, + val markup: String, + /** See [Text.editable]: false for a copy of something inside an archive. */ + val editable: Boolean = true, + ) : Overlay + + /** What an APK says about itself, read without installing it. */ + data class Package( + val entry: FileEntry, + val info: ApkInfo? = null, + /** `true` until [info] has been read, so a failure is not mistaken for a slow read. */ + val loading: Boolean = true, + ) : Overlay data class Usage( val path: StoragePath, @@ -135,6 +187,14 @@ data class BrowserUiState( val searchRoot: StoragePath? = null, /** `true` while a search is still finding things. */ val searching: Boolean = false, + /** Which folder tree a search looks at, or whether it asks the index instead. */ + val searchScope: SearchScope = SearchScope.FOLDER, + /** `true` while a volume is being walked to fill the index. */ + val indexing: Boolean = false, + /** How many files the running index pass has seen, for the progress line. */ + val indexedCount: Int = 0, + /** When the index was last built, or `null` when it never has been. */ + val indexedAt: Long? = null, val sort: SortSpec = SortSpec(), val showHidden: Boolean = false, val viewMode: ViewMode = ViewMode.LIST, @@ -160,6 +220,21 @@ data class BrowserUiState( val undo: Undo? = null, /** A viewer, editor or the storage-usage view taking over the screen. */ val overlay: Overlay = Overlay.None, + /** + * A copy of an archive entry in app-private cache, while a preview of it is open. + * + * Tracked so it can be deleted again when the preview closes: the cache is the system's + * to prune, but a copy of a file the user only glanced at should not outlive the glance + * by any longer than it has to. + */ + val scratch: String? = null, + /** + * Where closing the current overlay goes back to, instead of to the folder. + * + * Set when a preview is opened *from* somewhere — an entry shown out of an archive, whose + * screen the user expects to be returned to rather than dropped out of. + */ + val returnTo: Overlay? = null, ) { val isRootView: Boolean get() = path == null diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt index 54f1b89..f4d52a4 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt @@ -21,21 +21,27 @@ import de.softwareapp_hb.filemanager.core.SortKey import de.softwareapp_hb.filemanager.core.SortSpec import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.core.TransferAction +import de.softwareapp_hb.filemanager.data.ApkInspector import de.softwareapp_hb.filemanager.data.DeleteRequestFactory import de.softwareapp_hb.filemanager.data.IncomingSource +import de.softwareapp_hb.filemanager.data.IndexedFile +import de.softwareapp_hb.filemanager.data.SearchIndex import de.softwareapp_hb.filemanager.data.StorageAccessMode import de.softwareapp_hb.filemanager.data.StorageLocation import de.softwareapp_hb.filemanager.data.StorageSource import de.softwareapp_hb.filemanager.data.TrashIndex import de.softwareapp_hb.filemanager.domain.ArchiveEngine +import de.softwareapp_hb.filemanager.domain.ArchiveEntry import de.softwareapp_hb.filemanager.domain.FileOperations import de.softwareapp_hb.filemanager.domain.FileSearch import de.softwareapp_hb.filemanager.domain.OperationFailure import de.softwareapp_hb.filemanager.domain.OperationKind import de.softwareapp_hb.filemanager.domain.OperationNotifier import de.softwareapp_hb.filemanager.domain.OperationResult +import de.softwareapp_hb.filemanager.domain.SearchIndexer import de.softwareapp_hb.filemanager.domain.Trash import de.softwareapp_hb.filemanager.domain.TrashBatch +import de.softwareapp_hb.filemanager.domain.TrashedEntry import de.softwareapp_hb.filemanager.domain.UsageAnalysis import de.softwareapp_hb.filemanager.domain.UsageNode import kotlinx.coroutines.CancellationException @@ -47,6 +53,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.io.File /** * Drives the browser screen. @@ -70,6 +77,18 @@ class BrowserViewModel( private val savedState: SavedStateHandle = SavedStateHandle(), private val incoming: IncomingSource? = null, private val trashIndex: TrashIndex = TrashIndex.None, + private val apkInspector: ApkInspector = ApkInspector.None, + /** Remembers what is on the volumes by name; [SearchIndex.None] searches live instead. */ + private val searchIndex: SearchIndex = SearchIndex.None, + private val clock: () -> Long = System::currentTimeMillis, + /** + * Where a preview may copy something that has to be a real file to be shown; `null` + * disables those previews. + * + * A `File` rather than a `Context`: the ViewModel keeps its distance from the platform, + * and the application hands it a directory it already owns. + */ + private val scratchRoot: File? = null, ) : ViewModel() { private val _state = MutableStateFlow(restoredViewState()) @@ -81,6 +100,9 @@ class BrowserViewModel( /** The running recursive search, so the next keystroke can supersede it. */ private var searchJob: Job? = null + /** Makes each preview copy a distinct name, so two previews cannot overwrite each other. */ + private var scratchCounter: Int = 0 + init { refresh() } @@ -277,9 +299,20 @@ class BrowserViewModel( searchJob?.cancel() _state.update { it.copy(query = query) } - val root = _state.value.path val source = storage.activeSource() - if (query.isBlank() || root == null || source == null) { + if (query.isBlank() || source == null) { + _state.update { it.copy(searchRoot = null, searching = false) } + recompute() + return + } + + if (_state.value.searchScope == SearchScope.EVERYWHERE) { + searchEverywhere(query, source) + return + } + + val root = _state.value.path + if (root == null) { _state.update { it.copy(searchRoot = null, searching = false) } recompute() return @@ -300,6 +333,104 @@ class BrowserViewModel( } } + /** Look at a different folder tree, or stop walking and ask the index instead. */ + fun setSearchScope(scope: SearchScope) { + if (_state.value.searchScope == scope) return + _state.update { it.copy(searchScope = scope) } + + viewModelScope.launch { + _state.update { it.copy(indexedAt = searchIndex.indexedAt()) } + } + // Whatever is typed is re-answered under the new scope rather than left showing + // results that came from somewhere else. + setQuery(_state.value.query) + } + + /** + * Search what the index knows, on every volume at once. + * + * Two things make this honest rather than merely fast. Indexed rows are *candidates*: the + * filesystem is the truth, so every hit is checked and the ones whose file has gone are + * dropped — and forgotten, so the next search does not pay for them again. And the index + * is rebuilt when it is missing or old, because a search that silently omits what arrived + * since the last walk is worse than one that spends a minute being sure. + */ + private fun searchEverywhere(query: String, source: StorageSource) { + // Results can come from anywhere, so the row subtitle shows the whole parent path + // rather than a path relative to a folder the search never had. + _state.update { + it.copy(searchRoot = StoragePath.Root, searching = true, entries = emptyList()) + } + + searchJob = viewModelScope.launch { + val age = searchIndex.indexedAt() + if (age == null || clock() - age > INDEX_FRESHNESS_MILLIS) rebuild(source) + + val hits = runCatching { searchIndex.find(query, FileSearch.DEFAULT_LIMIT) } + .getOrDefault(emptyList()) + val confirmed = ArrayList(hits.size) + for (hit in hits) { + if (!currentCoroutineContext().isActive) return@launch + if (source.exists(hit.path)) { + confirmed += hit.toEntry() + } else { + // An index row whose file has gone is a row that has stopped being useful. + searchIndex.forget(hit.path) + } + } + + _state.update { current -> + if (current.query != query) { + current + } else { + current.copy( + entries = current.sort.sort(confirmed), + searching = false, + indexedAt = current.indexedAt ?: age, + ) + } + } + } + } + + /** + * Walk every volume and replace what the index knows. + * + * Deliberately not a foreground service: a scan runs while this screen is up, and stops + * when it is not. Nothing is left half-written — an interrupted volume keeps its old + * timestamp — so stopping early costs freshness, never correctness. + */ + fun rebuildSearchIndex() { + val source = storage.activeSource() ?: return + viewModelScope.launch { + rebuild(source) + setQuery(_state.value.query) + } + } + + private suspend fun rebuild(source: StorageSource) { + val volumes = storage.volumes().map { it.root }.ifEmpty { listOf(StoragePath.Root) } + val indexer = SearchIndexer(source, searchIndex, clock) + + _state.update { it.copy(indexing = true, indexedCount = 0) } + var seen = 0 + for (volume in volumes) { + if (!currentCoroutineContext().isActive) break + seen += indexer.index(volume, _state.value.showHidden) { inVolume -> + _state.update { it.copy(indexedCount = seen + inVolume) } + } + } + _state.update { it.copy(indexing = false, indexedAt = searchIndex.indexedAt()) } + } + + /** An indexed row as a listing row: the same shape the walk would have produced. */ + private fun IndexedFile.toEntry() = FileEntry( + path = path, + isDirectory = isDirectory, + sizeBytes = sizeBytes, + lastModifiedMillis = modifiedMillis, + ) + fun setSort(key: SortKey) { _state.update { it.copy(sort = it.sort.copy(key = key)) } recompute() @@ -395,7 +526,15 @@ class BrowserViewModel( // ------------------------------------------------------------------ opening - /** Open an entry: folders navigate, images and text open in the built-in viewers. */ + /** + * Open an entry, in whichever place can actually show it. + * + * A tap means "show me this", so each kind goes to whatever can draw it: a folder + * navigates, an image and a text file get the built-in viewer and editor, an archive + * its contents, a PDF its pages, audio and video a player, markup its own rendering — + * and anything left goes to the system, which may well have a better answer than this + * app does. + */ fun open(entry: FileEntry) { // While another app is waiting for a file, "open" on a *file* means "hand this one // back". Folders still navigate — without that the user could never reach the file @@ -407,12 +546,189 @@ class BrowserViewModel( when { entry.isDirectory -> openDirectory(entry.path) - entry.kind == FileKind.IMAGE -> _state.update { it.copy(overlay = Overlay.Image(entry)) } + + // An SVG is markup that the raster image viewer cannot draw at all, so the + // image case routes it away rather than letting it end in a blank frame. + entry.kind == FileKind.IMAGE -> openImage(entry) + + entry.kind == FileKind.ARCHIVE && ArchiveFormats.isExtractable(entry.name) -> + openArchive(entry) + + entry.kind == FileKind.DOCUMENT && isPdfName(entry.name) -> openPdf(entry) + + entry.kind == FileKind.VIDEO || entry.kind == FileKind.AUDIO -> openMedia(entry) + + isMarkupName(entry.name) -> openMarkup(entry) + + entry.kind == FileKind.APK -> openApk(entry) + entry.kind == FileKind.TEXT -> editText(entry) - else -> _state.update { it.copy(pendingOpen = entry) } + + else -> openWith(entry) } } + /** + * Show an image, or say why it cannot be shown. + * + * The viewer loads a real filesystem path through Coil, and the SAF fallback has none + * of those — its paths are relative to the granted tree while still looking absolute, + * so a preview there would amount to a blank frame. + */ + private fun openImage(entry: FileEntry) { + // An SVG is markup, not a raster image: Coil has no decoder for it without another + // artifact, so the frame would simply stay empty. The web engine draws it — and + // draws it from text, which is the one thing the SAF fallback can supply. + if (entry.name.endsWith(".svg", ignoreCase = true)) { + openMarkup(entry) + return + } + + if (_state.value.mode != StorageAccessMode.ALL_FILES) { + message(R.string.message_preview_needs_full_access) + return + } + _state.update { it.copy(overlay = Overlay.Image(entry)) } + } + + /** + * List what an archive holds, without unpacking any of it. + * + * Deliberately *not* gated on the access mode: the listing streams through whatever + * backend is in effect, so it works in the SAF fallback too — except for 7z, whose + * header sits at the end of the file and needs a seekable channel, which the engine + * reports as a failure with a reason rather than as an empty archive. + */ + fun openArchive(entry: FileEntry) { + val source = storage.activeSource() ?: return + + _state.update { it.copy(overlay = Overlay.Archive(entry, loading = true)) } + viewModelScope.launch { + val entries = runCatching { ArchiveEngine(source).list(entry.path) }.getOrNull() + + _state.update { current -> + val overlay = current.overlay as? Overlay.Archive ?: return@update current + if (entries == null) current.copy(overlay = Overlay.None) + else current.copy(overlay = overlay.copy(entries = entries, loading = false)) + } + // The engine's own reason is English prose from a library, so it is not shown; + // the message says what the user needs to know either way. + if (entries == null) message(R.string.message_archive_unreadable) + } + } + + /** Draw a PDF's pages, which needs a real file the platform can open a descriptor for. */ + private fun openPdf(entry: FileEntry) { + if (_state.value.mode != StorageAccessMode.ALL_FILES) { + message(R.string.message_preview_needs_full_access) + return + } + _state.update { it.copy(overlay = Overlay.Pdf(entry)) } + } + + /** Play audio or video, which like the image viewer needs a real path. */ + private fun openMedia(entry: FileEntry) { + if (_state.value.mode != StorageAccessMode.ALL_FILES) { + message(R.string.message_preview_needs_full_access) + return + } + _state.update { it.copy(overlay = Overlay.Media(entry)) } + } + + /** + * Render markup from its text. + * + * Text is the one thing the SAF fallback *can* hand over — everything richer needs a + * real file — so this is the one preview that behaves identically in both modes. + */ + private fun openMarkup(entry: FileEntry) { + val source = storage.activeSource() ?: return + + viewModelScope.launch { + val markup = runCatching { + source.openRead(entry.path).use { it.readBytes().decodeToString() } + }.getOrNull() + + if (markup == null) { + message(R.string.message_preview_unreadable) + } else { + _state.update { it.copy(overlay = Overlay.Markup(entry, markup)) } + } + } + } + + /** Read what an APK says about itself, without installing it. */ + private fun openApk(entry: FileEntry) { + if (_state.value.mode != StorageAccessMode.ALL_FILES) { + message(R.string.message_preview_needs_full_access) + return + } + + // Opened first and filled in second, so the screen can show that something is + // happening rather than nothing at all. + _state.update { it.copy(overlay = Overlay.Package(entry)) } + viewModelScope.launch { + val info = apkInspector.inspect(entry.path.value) + _state.update { current -> + val overlay = current.overlay as? Overlay.Package ?: return@update current + current.copy(overlay = overlay.copy(info = info, loading = false)) + } + } + } + + /** `true` for a name ending in `.pdf`, which the app can draw a page at a time. */ + private fun isPdfName(name: String): Boolean = name.endsWith(".pdf", ignoreCase = true) + + /** + * `true` for markup the app should render instead of editing. + * + * `.html` and `.svg` are classified as text and image respectively, so this is asked + * before both. The text editor stays reachable from the preview itself, for anyone who + * wants the source rather than the rendering. + */ + private fun isMarkupName(name: String): Boolean = + MARKUP_EXTENSIONS.any { name.endsWith(".$it", ignoreCase = true) } + + /** + * What the app can show for a name that lives inside an archive, if anything. + * + * Only worth offering when there is something to show it with: everything except text + * and markup needs a real file, and a nested archive would mean recursing, which is not + * a preview. `null` means "extract it and open it elsewhere". + */ + private fun archivePreviewOf(name: String): ArchivePreview? = when { + isMarkupName(name) -> ArchivePreview.MARKUP + FileKind.of(name, isDirectory = false) == FileKind.TEXT -> ArchivePreview.TEXT + isPdfName(name) -> ArchivePreview.PDF + else -> when (FileKind.of(name, isDirectory = false)) { + FileKind.IMAGE -> ArchivePreview.IMAGE + FileKind.VIDEO, FileKind.AUDIO -> ArchivePreview.MEDIA + else -> null + } + } + + /** + * Hand [entry] to the system's "open with" chooser, whatever kind it is. + * + * [open] answers a tap, and for an image or a text file that means the built-in viewer + * or editor — the right default, since those exist. This is the deliberate way out of + * that default, for a file another app plainly handles better. + */ + fun openWith(entry: FileEntry) { + // Another app can only be given a real path, and the SAF fallback has none of those: + // its paths are relative to the granted tree while still looking absolute, so the + // access mode is the only honest test. Saying so beats a chooser that then cannot + // read the file it was handed. + if (_state.value.mode != StorageAccessMode.ALL_FILES) { + message(R.string.message_open_needs_full_access) + return + } + _state.update { it.copy(pendingOpen = entry) } + } + + /** The launcher could not be built, or nothing claimed it; the screen supplies which. */ + fun openFailed(@StringRes textRes: Int) = message(textRes) + fun consumeOpen() { _state.update { it.copy(pendingOpen = null) } } @@ -496,14 +812,28 @@ class BrowserViewModel( * A name taken again in the meantime is not overwritten — see [Trash.restore] — so the * item comes back beside whatever holds the name now. */ - fun restoreFromTrash(batch: TrashBatch) { + fun restoreFromTrash(batch: TrashBatch) = restoreBatch(batch) { trash -> + trash.restore(batch) { notifier.update(it) } + } + + /** + * Put one staged item back, leaving the rest of its batch in the trash. + * + * The batch is forgotten once it holds nothing restorable any more, so a trash screen + * that restored its rows one by one does not keep listing an empty batch. + */ + fun restoreFromTrash(batch: TrashBatch, entry: TrashedEntry) = restoreBatch(batch) { trash -> + trash.restore(entry, batch) { notifier.update(it) } + } + + private fun restoreBatch(batch: TrashBatch, restore: suspend (Trash) -> OperationResult) { val source = storage.activeSource() ?: return viewModelScope.launch { _state.update { it.copy(loading = true) } notifier.start(OperationKind.MOVE) val result = try { - Trash.of(source, batch, trashIndex).restore(batch) { notifier.update(it) } + restore(Trash.of(source, batch, trashIndex)) } catch (cancellation: CancellationException) { throw cancellation } finally { @@ -525,7 +855,14 @@ class BrowserViewModel( } fun closeOverlay() { - _state.update { it.copy(overlay = Overlay.None) } + // A preview of an archive entry may have left a copy in the cache; closing the + // preview is the end of that copy's reason to exist. + discardScratch() + + // A preview opened *from* the archive list goes back to it: closing a file you were + // reading inside a zip should not throw you out of the zip. + val back = _state.value.returnTo + _state.update { it.copy(overlay = back ?: Overlay.None, returnTo = null) } } // ---------------------------------------------------------------- archives @@ -593,6 +930,170 @@ class BrowserViewModel( } } + /** + * Write one entry of the archive the preview is showing into the folder the archive + * sits in, keeping the entry's own relative path. + * + * Keeping the path is what stops two entries called `notes.txt` from two folders in the + * same archive from landing on top of each other. The preview stays open: extracting + * one file is rarely the only thing the user wants out of an archive. + */ + fun extractPreviewEntry(entry: ArchiveEntry) { + val overlay = _state.value.overlay as? Overlay.Archive ?: return + val source = storage.activeSource() ?: return + val destination = overlay.entry.path.parent ?: return + + viewModelScope.launch { + _state.update { it.copy(loading = true) } + notifier.start(OperationKind.EXTRACT) + val result = try { + ArchiveEngine(source) + .extractEntry(overlay.entry.path, entry, destination) { notifier.update(it) } + } catch (cancellation: CancellationException) { + throw cancellation + } finally { + notifier.stop() + } + _state.update { it.copy(loading = false) } + + if (result.succeeded) { + messagePlural(R.plurals.message_extracted, result.completed) + } else { + report(result) + } + // The folder behind the preview has a new file in it now. + _state.value.path?.let { load(it) } + } + } + + /** + * Show one entry of the archive the preview is showing, without extracting it anywhere + * the user can see. + * + * Text and markup are read into memory, so the commonest question — "what does this + * readme say?" — writes nothing at all and works in both storage modes. Everything else + * has to be a real file, because a decoder cannot be handed a few bytes, so the entry is + * copied into the app's own cache: private, invisible to the browser, and deleted again + * when the preview closes. + */ + fun previewArchiveEntry(entry: ArchiveEntry) { + val overlay = _state.value.overlay as? Overlay.Archive ?: return + val source = storage.activeSource() ?: return + if (entry.isDirectory) return + + val kind = archivePreviewOf(entry.name) + if (kind == null) { + message(R.string.message_preview_needs_extract) + return + } + // A known-big entry is refused before anything is read: the point of the cap is not + // to fail slowly. + if (entry.sizeBytes?.let { it > PREVIEW_MAX_BYTES } == true) { + message(R.string.message_preview_too_large) + return + } + + val engine = ArchiveEngine(source) + viewModelScope.launch { + _state.update { it.copy(loading = true) } + + if (kind == ArchivePreview.TEXT || kind == ArchivePreview.MARKUP) { + val bytes = runCatching { + engine.readEntry(overlay.entry.path, entry, PREVIEW_MAX_BYTES) + }.getOrNull() + _state.update { it.copy(loading = false) } + if (bytes == null) { + message(R.string.message_preview_unreadable) + return@launch + } + val copy = copyOf(overlay.entry, entry) + val text = bytes.decodeToString() + _state.update { + it.copy( + returnTo = overlay, + overlay = if (kind == ArchivePreview.MARKUP) { + Overlay.Markup(copy, text, editable = false) + } else { + Overlay.Text(copy, text, editable = false) + }, + ) + } + return@launch + } + + val file = reserveScratch(entry.name) + if (file == null) { + _state.update { it.copy(loading = false) } + message(R.string.message_preview_unreadable) + return@launch + } + + val written = runCatching { + engine.extractEntryToFile(overlay.entry.path, entry, file, PREVIEW_MAX_BYTES) + }.getOrDefault(false) + _state.update { it.copy(loading = false) } + if (!written) { + runCatching { file.delete() } + message(R.string.message_preview_unreadable) + return@launch + } + + // The copy is a real local file, so it goes straight to the viewer that suits it + // — including in the SAF fallback, where the archive itself is not one. + val copy = copyOf(overlay.entry, entry, StoragePath.of(file.absolutePath)) + _state.update { + it.copy( + scratch = file.absolutePath, + returnTo = overlay, + overlay = when (kind) { + ArchivePreview.PDF -> Overlay.Pdf(copy) + ArchivePreview.MEDIA -> Overlay.Media(copy) + else -> Overlay.Image(copy) + }, + ) + } + } + } + + /** + * The entry as a viewer sees it: its own name, tied either to where it *would* land if it + * were extracted or to the copy that was just written. + */ + private fun copyOf( + archive: FileEntry, + entry: ArchiveEntry, + path: StoragePath = archive.path.child(entry.name), + ) = FileEntry( + path = path, + isDirectory = false, + sizeBytes = entry.sizeBytes ?: 0L, + lastModifiedMillis = 0L, + ) + + /** A file to copy one entry into, or `null` when this build has nowhere to put one. */ + private fun reserveScratch(name: String): File? { + val root = scratchRoot ?: return null + + // One folder per preview, holding the entry under its own name — a viewer shows the + // file's name in its title bar, and a copy called `0-shot.png` would read as a file + // that came from somewhere else. The folder carries the counter instead. + val leaf = name.substringAfterLast('/').replace(SCRATCH_UNSAFE_CHARS, "_").ifEmpty { "entry" } + val folder = File(root, "${SCRATCH_PREVIEW_PREFIX}${scratchCounter++}") + if (!folder.isDirectory && !folder.mkdirs()) return null + return File(folder, leaf) + } + + /** Delete the copy a preview made, if it made one, and the folder that held it. */ + private fun discardScratch() { + val path = _state.value.scratch ?: return + val file = File(path) + runCatching { file.delete() } + // Then the folder, only if it is empty. Deliberately not a recursive delete: a + // mistake here can fail to tidy up, but it cannot remove a tree. + runCatching { file.parentFile?.takeIf { it.list()?.isEmpty() == true }?.delete() } + _state.update { it.copy(scratch = null) } + } + // ---------------------------------------------------------------- clipboard fun copyToClipboard(paths: List, cut: Boolean) { @@ -1037,6 +1538,40 @@ class BrowserViewModel( /** Used when the sending app offers no name at all for a shared file. */ private const val FALLBACK_RECEIVED_NAME = "shared-file" + /** + * Names the app draws itself rather than handing to a text editor or a raster + * viewer: an `.svg` is an image that Coil cannot decode, and rendered HTML is what + * a tap on a web page should mean. + */ + private val MARKUP_EXTENSIONS = setOf("html", "htm", "xhtml", "svg") + + /** The folder inside the app's cache that a preview copy is written to. */ + const val SCRATCH_DIRECTORY = "preview" + + /** One folder per preview inside [SCRATCH_DIRECTORY], so a copy keeps its own name. */ + private const val SCRATCH_PREVIEW_PREFIX = "copy-" + + /** + * The most an entry inside an archive may be expanded to for a preview. + * + * Room for a document or a short clip, and small enough that a mistake cannot fill + * the cache. Enforced again while decompressing, because the size an archive's index + * claims is exactly what a compression bomb would lie about. + */ + const val PREVIEW_MAX_BYTES = 64L * 1024L * 1024L + + /** Anything that has no business in a file name, for a copy of an archive entry. */ + private val SCRATCH_UNSAFE_CHARS = Regex("[^A-Za-z0-9._-]") + + /** + * How old the index may be before a search rebuilds it rather than trusting it. + * + * Fifteen minutes is a judgement, not a measurement: long enough that a hunt through + * one folder does not re-walk a volume, short enough that "I just downloaded it" + * still finds it. What makes the number safe is that the age is on screen either way. + */ + const val INDEX_FRESHNESS_MILLIS = 15L * 60L * 1000L + val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] @@ -1048,8 +1583,19 @@ class BrowserViewModel( savedState = createSavedStateHandle(), incoming = app.incoming, trashIndex = app.trashIndex, + apkInspector = app.apkInspector, + searchIndex = app.searchIndex, + scratchRoot = app.cacheDir.resolve(SCRATCH_DIRECTORY), ) } } } } + +/** + * How an entry inside an archive can be shown. + * + * [TEXT] and [MARKUP] come out of the archive as bytes; the rest need a copy on disk before + * the platform will open them, which is the difference this type is here to keep. + */ +private enum class ArchivePreview { TEXT, MARKUP, IMAGE, PDF, MEDIA } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt index 438141f..6d02558 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt @@ -3,12 +3,19 @@ package de.softwareapp_hb.filemanager.ui.browser import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -19,10 +26,21 @@ import de.softwareapp_hb.filemanager.core.FileSize import de.softwareapp_hb.filemanager.core.Timestamps import de.softwareapp_hb.filemanager.text.labelRes -/** Name, location, size, type and modified date for one entry. */ +/** + * Name, location, size, type and modified date for one entry. + * + * [onOpenWith] is the deliberate way past this app's own viewers: an image or a text file + * opens in the built-in one on a tap, so handing it to another app needs a visible action + * rather than a long press nobody would guess at. Omitted for a folder, which is not + * something another app can be handed. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) { +fun DetailsSheet( + entry: FileEntry, + onDismiss: () -> Unit, + onOpenWith: (() -> Unit)? = null, +) { ModalBottomSheet(onDismissRequest = onDismiss) { Column(modifier = Modifier.padding(bottom = 32.dp)) { Text( @@ -31,6 +49,20 @@ fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) { modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp), ) DetailLines(entry) + if (onOpenWith != null && !entry.isDirectory) { + TextButton( + onClick = onOpenWith, + modifier = Modifier.padding(start = 12.dp, top = 8.dp), + ) { + Icon( + imageVector = Icons.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.browser_action_open_with)) + } + } } } } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/EntryIntents.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/EntryIntents.kt index f7db06f..36d2b7d 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/EntryIntents.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/EntryIntents.kt @@ -11,9 +11,11 @@ import java.io.File /** * Wraps an entry's path in a `content://` URI another app may read. * - * `null` when the path is not a real file — which is every item reached through the SAF - * fallback, whose paths are relative to the granted tree rather than filesystem paths. - * Opening and sharing are therefore all-files-mode features for now. + * `null` when no URI can be built — a path the provider does not cover, or a file that has + * since gone. That is deliberately *not* how the SAF fallback is caught: paths there are + * relative to the granted tree yet still look absolute (`/notes.txt`), so a check on the + * shape of a path would pass and hand another app a URI pointing at nothing. Callers test + * the access mode instead — see `BrowserViewModel.openWith`. */ fun contentUriFor(context: Context, entry: FileEntry): Uri? = runCatching { FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", File(entry.path.value)) @@ -26,10 +28,31 @@ fun viewIntent(context: Context, entry: FileEntry): Intent? = contentUriFor(cont .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } -/** The "share" intent, or `null` when the entry cannot be handed out. */ -fun shareIntent(context: Context, entry: FileEntry): Intent? = contentUriFor(context, entry)?.let { uri -> - Intent(Intent.ACTION_SEND) - .setType(MimeTypes.forEntry(entry)) - .putExtra(Intent.EXTRA_STREAM, uri) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) +/** + * The "share" intent for [entries], or `null` when any of them cannot be handed out. + * + * One file goes out as `ACTION_SEND` with a single `EXTRA_STREAM`, several as + * `ACTION_SEND_MULTIPLE` with a list — the two shapes receivers actually implement, and + * many handle only one of them. The batch is refused whole rather than sent short: a + * chooser that silently dropped a file would be worse than no chooser at all. + * + * The read grant covers every URI in the list, so the receiving app can open all of them. + */ +fun shareIntent(context: Context, entries: List): Intent? { + if (entries.isEmpty()) return null + + val uris = entries.map { contentUriFor(context, it) ?: return null } + val type = MimeTypes.forIntent(entries) + + return if (uris.size == 1) { + Intent(Intent.ACTION_SEND) + .setType(type) + .putExtra(Intent.EXTRA_STREAM, uris.single()) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } else { + Intent(Intent.ACTION_SEND_MULTIPLE) + .setType(type) + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt index ab644bb..f4debd5 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.ContentCut import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Unarchive @@ -81,6 +82,7 @@ fun SelectionBar( onDelete: () -> Unit, onDetails: () -> Unit, onRename: () -> Unit, + onOpenWith: () -> Unit, onShare: () -> Unit, onCompress: () -> Unit, onExtract: () -> Unit, @@ -95,14 +97,18 @@ fun SelectionBar( ) /* * The actions must not depend on the window being wide enough to hold all of them. - * With a single archive selected there are ten buttons, which at the 48 dp minimum - * touch size need ~480 dp — more than a 411 dp phone has. In a plain row Compose lays - * the surplus out past the right edge, where nothing can reach it: measured on a - * 1080 px (411 dp) screen, "Extract", "Select all" and "Clear selection" were drawn - * off-window and were unreachable, while the same selection in a 2400 px landscape - * window showed all ten. Scrolling the row keeps every action reachable at any width - * and at any font scale. + * With a single archive selected the bar already held ten `IconButton`s, and eleven + * now that `Open with` joins them — ten alone need ~480 dp at the 48 dp minimum + * touch size, plus the item-count label, against the 411 dp a normal phone has. + * In a plain row Compose lays the surplus out past the right edge, where nothing can + * reach it: measured on a 1080 px (411 dp) screen, "Extract", "Select all" and + * "Clear selection" were drawn off-window and were unreachable, while the same + * selection in a 2400 px landscape window showed all ten. Scrolling the row keeps + * every action reachable at any width and at any font scale, however many there are. */ + val selected = state.selectedEntries + val singled = selected.singleOrNull() + Row( modifier = Modifier .weight(1f, fill = false) @@ -116,13 +122,28 @@ fun SelectionBar( IconButton(onClick = onCut) { Icon(Icons.Filled.ContentCut, contentDescription = stringResource(R.string.browser_action_cut)) } - if (state.selectedEntries.size == 1) { + if (singled != null) { IconButton(onClick = onRename) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.browser_action_rename)) } IconButton(onClick = onDetails) { Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.browser_action_details)) } + } + /* + * Only files: a folder is not something another app can be handed. Opening is + * offered for one item because `ACTION_VIEW` takes a single URI, but sharing + * takes any number — which is what a batch of photos is actually for. + */ + if (selected.isNotEmpty() && selected.none { it.isDirectory }) { + if (singled != null) { + IconButton(onClick = onOpenWith) { + Icon( + Icons.Filled.OpenInNew, + contentDescription = stringResource(R.string.browser_action_open_with), + ) + } + } IconButton(onClick = onShare) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.browser_action_share)) } @@ -133,7 +154,7 @@ fun SelectionBar( IconButton(onClick = onCompress) { Icon(Icons.Filled.Archive, contentDescription = stringResource(R.string.browser_action_compress)) } - if (state.selectedEntries.singleOrNull()?.let { ArchiveFormats.isExtractable(it.name) } == true) { + if (singled?.let { ArchiveFormats.isExtractable(it.name) } == true) { IconButton(onClick = onExtract) { Icon(Icons.Filled.Unarchive, contentDescription = stringResource(R.string.browser_action_extract)) } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/trash/TrashScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/trash/TrashScreen.kt index ac3304a..4f245ef 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/trash/TrashScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/trash/TrashScreen.kt @@ -1,14 +1,20 @@ package de.softwareapp_hb.filemanager.ui.trash import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -23,18 +29,23 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileSize import de.softwareapp_hb.filemanager.core.Timestamps import de.softwareapp_hb.filemanager.domain.TrashBatch +import de.softwareapp_hb.filemanager.domain.TrashedEntry import de.softwareapp_hb.filemanager.ui.browser.EmptyState import de.softwareapp_hb.filemanager.ui.browser.itemCountLabel /** * What the trash still holds, and the way to put it back. * - * A row is one *batch*, not one file: a batch is what a single delete did, which is the unit - * the user performed and the unit the index records. Restoring a batch is therefore also the - * unit that makes sense — "put back what I deleted at 14:20", not "resurrect one file". + * A *row* is one file, because a file is what the user deleted and what they want to + * recognise here: its name, its size, and the folder it will go back to. Rows are grouped + * under the batch that staged them — a batch is one delete, and one timestamp — so "put back + * everything I deleted at 14:20" is still a single tap when a batch held more than one item. + * A batch of exactly one file says nothing about a count: the filename is the whole story. * * This is the screen the snackbar could never be. Its undo handle lived in memory, so a * delete from a previous session was unreachable even though the files were sitting right @@ -46,6 +57,7 @@ fun TrashScreen( batches: List, loading: Boolean, onRestore: (TrashBatch) -> Unit, + onRestoreEntry: (TrashBatch, TrashedEntry) -> Unit, onClose: () -> Unit, ) { Scaffold( @@ -76,8 +88,16 @@ fun TrashScreen( ) } else { LazyColumn(modifier = Modifier.fillMaxSize()) { - items(items = batches, key = { it.folder.value }) { batch -> - TrashRow(batch = batch, onRestore = { onRestore(batch) }) + for (batch in batches) { + item(key = "batch:${batch.folder.value}") { + BatchHeader(batch = batch, onRestoreAll = { onRestore(batch) }) + } + items(items = batch.entries, key = { it.staged.value }) { entry -> + TrashEntryRow( + entry = entry, + onRestore = { onRestoreEntry(batch, entry) }, + ) + } } } } @@ -91,25 +111,82 @@ fun TrashScreen( } } +/** When a batch was deleted, how many items it held, and a way to put all of them back. */ @Composable -private fun TrashRow(batch: TrashBatch, onRestore: () -> Unit) { +private fun BatchHeader(batch: TrashBatch, onRestoreAll: () -> Unit) { + val deletedAt = Timestamps.format(batch.stagedAtMillis).takeIf { it.isNotEmpty() } + // A count is only worth showing when there is more than one; a lone file is named by + // its own row, and "1 item" there tells the user nothing the filename does not. + val count = itemCountLabel(batch.entries.size).takeIf { batch.entries.size > 1 } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = listOfNotNull(deletedAt, count).joinToString(" · "), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (batch.entries.size > 1) { + TextButton(onClick = onRestoreAll) { + Text(stringResource(R.string.trash_restore_all)) + } + } + } +} + +/** One trashed file: its name, its size, and the folder it will be put back into. */ +@Composable +private fun TrashEntryRow(entry: TrashedEntry, onRestore: () -> Unit) { ListItem( - headlineContent = { Text(itemCountLabel(batch.entries.size)) }, - supportingContent = { - Text( - text = listOfNotNull( - Timestamps.format(batch.stagedAtMillis).takeIf { it.isNotEmpty() }, - batch.entries.joinToString(", ") { it.original.name }, - ).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + leadingContent = { + Icon( + imageVector = if (entry.isDirectory) { + Icons.Filled.Folder + } else { + Icons.Filled.InsertDriveFile + }, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (entry.isDirectory) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) }, + headlineContent = { Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = { + Column { + Text( + text = if (entry.isDirectory) { + stringResource(R.string.browser_details_folder) + } else { + FileSize.format(entry.sizeBytes) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + entry.original.parent?.let { parent -> + Text( + text = stringResource(R.string.trash_restore_to, parent.value), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, trailingContent = { TextButton(onClick = onRestore) { Text(stringResource(R.string.trash_restore)) } }, ) + HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ApkInfoScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ApkInfoScreen.kt new file mode 100644 index 0000000..d887582 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ApkInfoScreen.kt @@ -0,0 +1,201 @@ +package de.softwareapp_hb.filemanager.ui.viewer + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.data.ApkInfo + +/** + * What an APK says about itself, read from its own manifest without installing it. + * + * The point is to answer "what is this?" before anyone commits to installing it — the name + * it would appear under, the package it would replace or sit beside, its version, what it + * asks for, and how old a device it needs. Nothing here installs anything: the installer is + * another app, and "Open with" already hands it over. + * + * An APK whose manifest cannot be read says so rather than showing an empty card, because + * that is itself worth knowing about a file being offered as an app. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ApkInfoScreen( + entry: FileEntry, + info: ApkInfo?, + loading: Boolean, + onClose: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.browser_action_close), + ) + } + }, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + when { + loading -> Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.apk_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + info == null -> Text( + text = stringResource(R.string.apk_unreadable), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + else -> ApkDetails(info = info) + } + } + } +} + +@Composable +private fun ApkDetails(info: ApkInfo) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val icon = info.icon + if (icon == null) { + Icon( + imageVector = Icons.Filled.Android, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Image( + bitmap = icon.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + } + Column { + Text(text = info.label, style = MaterialTheme.typography.titleMedium) + Text( + text = info.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + HorizontalDivider() + + ApkLine( + label = stringResource(R.string.apk_version), + value = info.versionName?.let { "$it (${info.versionCode})" } ?: info.versionCode.toString(), + ) + ApkLine( + label = stringResource(R.string.apk_min_android), + value = stringResource(R.string.apk_api_level, info.minSdk), + ) + ApkLine( + label = stringResource(R.string.apk_target_android), + value = stringResource(R.string.apk_api_level, info.targetSdk), + ) + + Text( + text = stringResource(R.string.apk_permissions), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + ) + + if (info.permissions.isEmpty()) { + Text( + text = stringResource(R.string.apk_no_permissions), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 24.dp), + ) + } else { + for (permission in info.permissions) { + // The last segment is the readable part: `android.permission.INTERNET` is + // otherwise the same prefix printed a dozen times. + Text( + text = permission.substringAfterLast('.'), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 2.dp), + ) + } + } + } +} + +@Composable +private fun ApkLine(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = value, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ArchivePreviewScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ArchivePreviewScreen.kt new file mode 100644 index 0000000..ff48cb8 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ArchivePreviewScreen.kt @@ -0,0 +1,198 @@ +package de.softwareapp_hb.filemanager.ui.viewer + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Unarchive +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.FileSize +import de.softwareapp_hb.filemanager.domain.ArchiveEntry +import de.softwareapp_hb.filemanager.ui.browser.EmptyState + +/** + * What an archive holds, read from its index without unpacking any of it. + * + * Rows are indented by their depth so the folder structure reads as a tree, and each one + * carries the uncompressed size the index claims — or says plainly that the archive does + * not store one, which a compression wrapper never does. + * + * A tap *previews* the entry in the viewer that suits it, which is what a row in a file + * list means everywhere else in this app and is the point of a preview screen. The action on + * the row writes the entry out beside the archive, keeping its path from inside the archive + * so two entries with the same name cannot land on top of each other — so both are one tap + * away, and neither is a surprise. + * + * Folder rows are deliberately inert: "extract this folder" would mean every entry beneath + * it, which is what the bar's extract-all action is for rather than something a stray tap + * should start. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArchivePreviewScreen( + entry: FileEntry, + entries: List, + loading: Boolean, + onPreviewEntry: (ArchiveEntry) -> Unit, + onExtractEntry: (ArchiveEntry) -> Unit, + onExtractAll: () -> Unit, + onClose: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.browser_action_close), + ) + } + }, + actions = { + IconButton(onClick = onExtractAll) { + Icon( + imageVector = Icons.Filled.Unarchive, + contentDescription = stringResource(R.string.browser_action_extract), + ) + } + }, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + if (entries.isEmpty() && !loading) { + EmptyState( + message = stringResource(R.string.archive_empty), + modifier = Modifier.fillMaxSize(), + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (entries.isNotEmpty()) { + item(key = "count") { + Text( + text = pluralStringResource( + R.plurals.browser_item_count, + entries.size, + entries.size, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp), + ) + } + } + items(items = entries, key = { it.name }) { archiveEntry -> + ArchiveEntryRow( + entry = archiveEntry, + onPreview = { onPreviewEntry(archiveEntry) }, + onExtract = { onExtractEntry(archiveEntry) }, + ) + } + } + } + + if (loading) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter), + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ArchiveEntryRow( + entry: ArchiveEntry, + onPreview: () -> Unit, + onExtract: () -> Unit, +) { + // Only files do anything: a folder inside an archive holds no bytes of its own, so + // previewing or extracting "it" would either do nothing or unpack a subtree nobody asked + // for. + ListItem( + modifier = Modifier + .fillMaxWidth() + .padding(start = (entry.depth * INDENT_DP).dp) + .then( + if (entry.isDirectory) { + Modifier + } else { + Modifier.combinedClickable(onClick = onPreview, onLongClick = onExtract) + }, + ), + leadingContent = { + Icon( + imageVector = if (entry.isDirectory) { + Icons.Filled.Folder + } else { + Icons.Filled.InsertDriveFile + }, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (entry.isDirectory) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + }, + headlineContent = { Text(entry.leafName, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = { + Text( + text = entry.sizeBytes?.let { FileSize.format(it) } + ?: stringResource(R.string.archive_entry_size_none), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailingContent = { + if (!entry.isDirectory) { + IconButton(onClick = onExtract) { + Icon( + imageVector = Icons.Filled.FileDownload, + contentDescription = stringResource(R.string.archive_extract_entry), + ) + } + } + }, + ) +} + +/** How far one level of nesting shifts a row, so the tree reads as a tree. */ +private const val INDENT_DP = 16 diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/MarkupPreviewScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/MarkupPreviewScreen.kt new file mode 100644 index 0000000..30fc0ec --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/MarkupPreviewScreen.kt @@ -0,0 +1,116 @@ +package de.softwareapp_hb.filemanager.ui.viewer + +import android.graphics.Color +import android.webkit.WebView +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.viewinterop.AndroidView +import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.MimeTypes + +/** + * Markup drawn by the platform's own web engine: rendered HTML, and the SVG that the raster + * image viewer cannot decode at all. + * + * The content is handed over as a string rather than a path, which is what lets it work in + * both storage modes — the markup came through the backend, and nothing here needs a real + * file. It is loaded with scripting off, network loads blocked and file access disabled: a + * preview has no business running anyone's code or reaching anywhere, and this app's + * whole promise is that nothing leaves the device. + * + * A page gets a white canvas rather than the app's background, because a document is + * written for paper — black text and black SVG strokes on a dark theme would be invisible. + * + * [editable] is false for a copy of something inside an archive: there is no file of its own + * for the text editor to open, so the way to the source is not offered. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MarkupPreviewScreen( + entry: FileEntry, + markup: String, + onEditText: () -> Unit, + onClose: () -> Unit, + editable: Boolean = true, +) { + val context = LocalContext.current + + // Built once per file and destroyed on the way out: a WebView holds a renderer and a + // native view of its own, and one per preview would leak them. + val webView = remember(entry.path.value) { + WebView(context).apply { + settings.javaScriptEnabled = false + settings.blockNetworkLoads = true + settings.allowFileAccess = false + settings.allowContentAccess = false + setBackgroundColor(Color.WHITE) + } + } + + DisposableEffect(webView) { + onDispose { + webView.stopLoading() + webView.destroy() + } + } + + LaunchedEffect(webView, markup) { + webView.loadDataWithBaseURL(null, markup, MimeTypes.forEntry(entry), ENCODING, null) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.browser_action_close), + ) + } + }, + actions = { + // The rendering is the default for a tap, but the source is still one + // action away — an editor is the other half of what markup is for. Not + // for a copy, though: there is no file of its own to edit. + if (editable) { + IconButton(onClick = onEditText) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.browser_action_edit_text), + ) + } + } + }, + ) + }, + ) { innerPadding -> + AndroidView( + factory = { webView }, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) + } +} + +private const val ENCODING = "utf-8" diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/MediaPlayerScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/MediaPlayerScreen.kt new file mode 100644 index 0000000..3a4baa3 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/MediaPlayerScreen.kt @@ -0,0 +1,250 @@ +package de.softwareapp_hb.filemanager.ui.viewer + +import android.media.MediaPlayer +import android.widget.MediaController +import android.widget.VideoView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.FileKind +import kotlinx.coroutines.delay +import java.util.Locale + +/** + * Audio and video, played from the file itself. + * + * Both use the platform's own decoders — `MediaPlayer` and `VideoView` — so this costs no + * dependency and no native code, and it plays exactly what the device can already play. + * Like the image viewer it needs a real path, which the ViewModel checks for before opening + * it. + * + * The two shapes are deliberately different: video gets the platform's transport controls + * over the picture, and audio gets a card, because a black rectangle with a scrubber is a + * poor face for a song. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaPlayerScreen(entry: FileEntry, onClose: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.browser_action_close), + ) + } + }, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + if (entry.kind == FileKind.VIDEO) { + VideoPreview(entry = entry) + } else { + AudioPreview(entry = entry) + } + } + } +} + +/** Video, with the platform's own transport controls over the picture. */ +@Composable +private fun VideoPreview(entry: FileEntry) { + var failed by remember(entry.path.value) { mutableStateOf(false) } + + if (failed) { + Unplayable() + return + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + VideoView(context).apply { + // A MediaController is the whole transport — play, pause, scrub, timing — for + // one call, rather than a hand-built bar that would have to keep itself in + // step with a player this code cannot see. + val controller = MediaController(context) + setMediaController(controller) + controller.setAnchorView(this) + + setOnErrorListener { _, _, _ -> + failed = true + true + } + setVideoPath(entry.path.value) + setOnPreparedListener { it.start() } + } + }, + ) +} + +/** Audio, as a card with a scrubber — the shape a song deserves. */ +@Composable +private fun AudioPreview(entry: FileEntry) { + val player = remember(entry.path.value) { MediaPlayer() } + var playing by remember(entry.path.value) { mutableStateOf(false) } + var failed by remember(entry.path.value) { mutableStateOf(false) } + var duration by remember(entry.path.value) { mutableStateOf(0L) } + var position by remember(entry.path.value) { mutableStateOf(0L) } + + // The player owns a native decoder, and releasing it on the way out is what stops a + // closed preview from holding the audio device. + DisposableEffect(player) { + onDispose { runCatching { player.release() } } + } + + LaunchedEffect(player, entry.path.value) { + // prepareAsync, not prepare: preparing blocks until the file has been decoded, and + // on the main thread that is a frozen screen on a large file. + runCatching { + player.setDataSource(entry.path.value) + player.setOnPreparedListener { prepared -> + duration = prepared.duration.toLong() + prepared.start() + playing = true + } + player.setOnCompletionListener { + playing = false + position = duration + } + player.setOnErrorListener { _, _, _ -> + failed = true + true + } + player.prepareAsync() + }.onFailure { failed = true } + } + + // Polled rather than listened for: MediaPlayer has no position callback, and four + // updates a second is smoother than the eye needs. + LaunchedEffect(playing) { + while (playing) { + position = runCatching { player.currentPosition.toLong() }.getOrDefault(0L) + delay(POSITION_POLL_MILLIS) + } + } + + if (failed) { + Unplayable() + return + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Filled.MusicNote, + contentDescription = null, + modifier = Modifier.size(96.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = entry.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(16.dp)) + + Slider( + value = position.toFloat(), + valueRange = 0f..duration.coerceAtLeast(1L).toFloat(), + onValueChange = { moved -> + position = moved.toLong() + runCatching { player.seekTo(moved.toInt()) } + }, + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = timeLabel(position), style = MaterialTheme.typography.labelSmall) + Text(text = timeLabel(duration), style = MaterialTheme.typography.labelSmall) + } + + Spacer(Modifier.height(16.dp)) + + FilledIconButton( + onClick = { + if (playing) runCatching { player.pause() } else runCatching { player.start() } + playing = !playing + }, + ) { + Icon( + imageVector = if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = stringResource( + if (playing) R.string.media_pause else R.string.media_play, + ), + ) + } + } +} + +@Composable +private fun Unplayable() { + Text( + text = stringResource(R.string.media_unplayable), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** `3:07` — unlocalised digits and a colon, like every other transport there is. */ +private fun timeLabel(millis: Long): String { + val seconds = (millis / 1000).coerceAtLeast(0L) + return String.format(Locale.US, "%d:%02d", seconds / 60, seconds % 60) +} + +/** Four updates a second: smoother than needed, and far cheaper than a wasted one. */ +private const val POSITION_POLL_MILLIS = 250L diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/PdfViewerScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/PdfViewerScreen.kt new file mode 100644 index 0000000..550fb43 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/PdfViewerScreen.kt @@ -0,0 +1,276 @@ +package de.softwareapp_hb.filemanager.ui.viewer + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.Closeable +import java.io.File + +/** + * A PDF open for rendering, or `null` when the platform could not open it. + * + * A [PdfRenderer] owns both a file descriptor and a native document handle, so the two are + * held in one object that is closed as a unit — a descriptor leaked per preview would + * eventually run the process out of them. + */ +private class OpenPdf( + private val descriptor: ParcelFileDescriptor, + private val renderer: PdfRenderer, +) : Closeable { + + val pageCount: Int get() = renderer.pageCount + + /** + * Draw page [index] to fit a [boxWidthPx] by [boxHeightPx] box, or `null` if it cannot + * be drawn. + * + * The page is painted on white first: a fresh bitmap is transparent, which in a dark + * theme would put black text on a black background and read as a blank page. + */ + fun render(index: Int, boxWidthPx: Int, boxHeightPx: Int): Bitmap? = runCatching { + renderer.openPage(index).use { page -> + val scale = minOf( + boxWidthPx.toFloat() / page.width, + boxHeightPx.toFloat() / page.height, + ) + val width = (page.width * scale).toInt().coerceAtLeast(1) + val height = (page.height * scale).toInt().coerceAtLeast(1) + + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap -> + Canvas(bitmap).drawColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + } + } + }.getOrNull() + + override fun close() { + runCatching { renderer.close() } + runCatching { descriptor.close() } + } + + companion object { + fun open(path: String): OpenPdf? { + val descriptor = runCatching { + ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY) + }.getOrNull() ?: return null + + val renderer = runCatching { PdfRenderer(descriptor) }.getOrNull() + if (renderer == null) { + runCatching { descriptor.close() } + return null + } + return OpenPdf(descriptor, renderer) + } + } +} + +/** + * A PDF, drawn a page at a time by the platform's own renderer. + * + * `PdfRenderer` ships with Android, so this costs no dependency and no native code — the + * same bargain the rest of the app makes. It needs a real file: a descriptor cannot be + * opened for a path that merely looks like one, which is why the ViewModel refuses to open + * this in the SAF fallback rather than showing an empty frame. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PdfViewerScreen(entry: FileEntry, onClose: () -> Unit) { + val pdf = remember(entry.path.value) { OpenPdf.open(entry.path.value) } + + // The page is a position, not a variable to increment: a swipe is a scroll, and a pager + // is what makes a scroll out of it. The buttons move the same thing, so the two can + // never disagree about where the reader is. + val pager = rememberPagerState(pageCount = { pdf?.pageCount ?: 0 }) + + DisposableEffect(pdf) { + onDispose { pdf?.close() } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.browser_action_close), + ) + } + }, + ) + }, + bottomBar = { if (pdf != null) PageBar(state = pager) }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + if (pdf == null) { + Text( + text = stringResource(R.string.pdf_unreadable), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + PdfPages(pdf = pdf, state = pager) + } + } + } +} + +/** The pages themselves, as a horizontally scrollable row that swipes one at a time. */ +@Composable +private fun PdfPages(pdf: OpenPdf, state: PagerState) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val density = LocalDensity.current + val pageWidth = with(density) { maxWidth.toPx().toInt() } + val pageHeight = with(density) { maxHeight.toPx().toInt() } + + HorizontalPager( + state = state, + // The pages either side of the visible one are drawn ahead of the swipe, so a + // turn reveals a page instead of a spinner. Each one is drawn on demand and + // dropped again, so a 500-page document still costs two bitmaps. + beyondViewportPageCount = 1, + modifier = Modifier.fillMaxSize(), + ) { index -> + PdfPage( + pdf = pdf, + index = index, + pageCount = state.pageCount, + boxWidthPx = pageWidth, + boxHeightPx = pageHeight, + ) + } + } +} + +/** One page, drawn when it comes into view and while it is near enough to be swiped to. */ +@Composable +private fun PdfPage( + pdf: OpenPdf, + index: Int, + pageCount: Int, + boxWidthPx: Int, + boxHeightPx: Int, +) { + var bitmap by remember(pdf, index, boxWidthPx, boxHeightPx) { mutableStateOf(null) } + + LaunchedEffect(pdf, index, boxWidthPx, boxHeightPx) { + // Off the main thread: drawing a page is tens of milliseconds of native work, which + // on the main thread is a visible stutter on every page turn. + bitmap = withContext(Dispatchers.Default) { pdf.render(index, boxWidthPx, boxHeightPx) } + } + + val drawn = bitmap + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (drawn == null) { + CircularProgressIndicator() + } else { + Image( + bitmap = drawn.asImageBitmap(), + contentDescription = stringResource(R.string.pdf_page_of, index + 1, pageCount), + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + ) + } + } +} + +/** Which page, out of how many, and the two buttons that move between them. */ +@Composable +private fun PageBar(state: PagerState) { + val scope = rememberCoroutineScope() + + Row( + modifier = Modifier + .fillMaxWidth() + // The window is edge-to-edge, so without this the bar is drawn *under* the + // system navigation buttons: it reads as cramped, and a tap aimed at a chevron + // can land on Back or Home instead. This bar is the bottom-most thing on the + // screen, so making room for those buttons is its job — a Scaffold hands its + // insets to the content, not to the bars it is given. + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + IconButton( + onClick = { scope.launch { state.animateScrollToPage(state.currentPage - 1) } }, + enabled = state.currentPage > 0, + ) { + Icon( + imageVector = Icons.Filled.ChevronLeft, + contentDescription = stringResource(R.string.pdf_previous_page), + ) + } + Text( + text = stringResource(R.string.pdf_page_of, state.currentPage + 1, state.pageCount), + style = MaterialTheme.typography.labelLarge, + ) + IconButton( + onClick = { scope.launch { state.animateScrollToPage(state.currentPage + 1) } }, + enabled = state.currentPage < state.pageCount - 1, + ) { + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = stringResource(R.string.pdf_next_page), + ) + } + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/TextEditorScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/TextEditorScreen.kt index d618364..443f2a0 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/TextEditorScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/TextEditorScreen.kt @@ -25,7 +25,13 @@ import androidx.compose.ui.unit.dp import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.core.FileEntry -/** A plain text editor for the text formats the browser classifies as [FileKind.TEXT]. */ +/** + * A plain text editor for the text formats the browser classifies as [FileKind.TEXT]. + * + * [editable] is false for a copy of something inside an archive: there is no file behind it + * to write to, so the field is read-only and the Save action is not offered at all, rather + * than being offered and then failing. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TextEditorScreen( @@ -33,6 +39,7 @@ fun TextEditorScreen( content: String, onSave: (String) -> Unit, onClose: () -> Unit, + editable: Boolean = true, ) { var text by remember(entry.path.value) { mutableStateOf(content) } @@ -49,8 +56,10 @@ fun TextEditorScreen( } }, actions = { - TextButton(onClick = { onSave(text) }) { - Text(stringResource(R.string.browser_action_save)) + if (editable) { + TextButton(onClick = { onSave(text) }) { + Text(stringResource(R.string.browser_action_save)) + } } }, ) @@ -59,6 +68,7 @@ fun TextEditorScreen( OutlinedTextField( value = text, onValueChange = { text = it }, + readOnly = !editable, modifier = Modifier .fillMaxSize() .padding(innerPadding) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bbc44cf..e546ae8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,15 @@ Search this folder Clear search + + Search everywhere + Search this folder only + + Indexing %1$s… + + Index updated %1$s + Not indexed yet + Rebuild New folder @@ -52,6 +61,7 @@ Rename Delete Share + Open with Select all Clear selection Undo @@ -69,6 +79,9 @@ Trash Nothing in the trash Restore + Restore all + + Restores to %1$s Trash More options Refresh @@ -171,6 +184,17 @@ Cancelled Saved The file could not be saved + + No app on this device can open this file + This file could not be handed to another app + Another app can only be given a file with full file access + + This preview needs full file access + This file could not be read + This archive could not be read + + This file is too large to preview — extract it first + This file has no preview — extract it to open it in another app This archive format is not supported Compressed %1$d file @@ -189,10 +213,46 @@ Save Close + Edit as text Storage usage Nothing to measure here %1$d file %1$d files + + + This archive is empty + Extract this file + Preview this file + + Not stored in the archive + + + + Page %1$d of %2$d + Previous page + Next page + This PDF could not be opened + + + Play + Pause + This file could not be played + + + Package + Version + Needs Android + Built for Android + Permissions + This app asks for no permissions + This APK could not be read + Reading the package… + + API %1$d + + + + %1$s / %2$s diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/FileEntryTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileEntryTest.kt index a1bb1f4..ec99e4b 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/core/FileEntryTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileEntryTest.kt @@ -50,4 +50,34 @@ class MimeTypesTest { MimeTypes.forEntry(dir.copy(path = StoragePath.of("/a/b.png"), isDirectory = false)), ) } + + /* + * The type an intent carries decides which apps the chooser offers, so the wrong answer + * hides receivers rather than failing loudly. + */ + + @Test + fun `an intent takes the type its items share`() { + assertEquals("image/png", MimeTypes.forIntent(listOf(file("a.png")))) + assertEquals("image/png", MimeTypes.forIntent(listOf(file("a.png"), file("b.png")))) + // Two extensions, one type: the batch still has a single honest answer. + assertEquals("image/jpeg", MimeTypes.forIntent(listOf(file("a.jpg"), file("b.jpeg")))) + } + + @Test + fun `a mixed batch falls back to the wildcard rather than hiding half the apps`() { + assertEquals(MimeTypes.ANY, MimeTypes.forIntent(listOf(file("a.png"), file("b.pdf")))) + } + + @Test + fun `an unknown extension is still shared as a file`() { + assertEquals(MimeTypes.BINARY, MimeTypes.forIntent(listOf(file("a.weird"), file("b.weird")))) + } + + private fun file(name: String) = FileEntry( + path = StoragePath.of("/a/$name"), + isDirectory = false, + sizeBytes = 1, + lastModifiedMillis = 0, + ) } diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndexTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndexTest.kt new file mode 100644 index 0000000..b434faa --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndexTest.kt @@ -0,0 +1,161 @@ +package de.softwareapp_hb.filemanager.data + +import android.database.sqlite.SQLiteDatabase +import de.softwareapp_hb.filemanager.core.StoragePath +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The matching is where an index can be quietly wrong — a substring that only agrees with the + * app's matcher by luck, a glob that is not anchored, an underscore that behaves as a + * wildcard — so this is proven against real SQLite rather than against a fake that would use + * [de.softwareapp_hb.filemanager.core.NameMatcher] and agree by construction. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SqliteSearchIndexTest { + + private val volume = "/storage/emulated/0" + + private fun index() = SqliteSearchIndex(RuntimeEnvironment.getApplication()) + + private fun file(name: String, inVolume: String = volume) = IndexedFile( + volume = inVolume, + path = StoragePath.of("$inVolume/$name"), + name = name.substringAfterLast('/'), + isDirectory = false, + sizeBytes = 10L, + modifiedMillis = 0L, + ) + + /** One finished pass over [volume], as an indexer would leave it. */ + private suspend fun SqliteSearchIndex.filled(vararg names: String, at: Long = 1000L) { + beginVolume(volume) + add(names.map { file(it) }) + endVolume(volume, at) + } + + private fun names(rows: List) = rows.map { it.name } + + @Test + fun `a plain query is a substring of the name, case-insensitively`() = runBlocking { + val index = index() + index.filled("holiday-photo.png", "report.pdf", "notes.txt") + + assertEquals(listOf("holiday-photo.png"), names(index.find("holiday", 10))) + assertEquals(listOf("report.pdf"), names(index.find("REPORT", 10))) + assertEquals(listOf("notes.txt"), names(index.find(".txt", 10))) + } + + @Test + fun `a query with a wildcard is a glob over the whole name`() = runBlocking { + val index = index() + index.filled("IMG_0001.png", "IMG_0002.png", "scan.png") + + assertEquals(listOf("IMG_0001.png", "IMG_0002.png"), names(index.find("IMG_*.png", 10))) + assertEquals(listOf("scan.png"), names(index.find("sc?n.png", 10))) + // Anchored at both ends, as NameMatcher's glob is: a pattern that only matches the + // tail of a name is not a match for that name. + assertEquals(emptyList(), names(index.find("0001*.png", 10))) + } + + @Test + fun `wildcards the user did not type stay literal`() = runBlocking { + val index = index() + index.filled("100%_done.txt", "100x done.txt") + + // `%` and `_` are LIKE's own wildcards. A search for them must not quietly become a + // search for something broader than what was typed. + assertEquals(listOf("100%_done.txt"), names(index.find("100%_", 10))) + } + + @Test + fun `results are capped and ordered by name`() = runBlocking { + val index = index() + index.filled("c.txt", "a.txt", "b.txt") + + assertEquals(listOf("a.txt", "b.txt"), names(index.find(".txt", 2))) + } + + @Test + fun `a volume is replaced wholesale, and the stalest one decides the age`() = runBlocking { + val index = index() + val card = "/storage/1A2B-3C4D" + index.filled("gone.txt") + index.beginVolume(card) + index.add(listOf(file("kept.txt", card))) + index.endVolume(card, 500L) + + // A fresh pass drops what the volume held, and only that volume's rows. + index.filled("new.txt", at = 2000L) + + assertEquals(listOf("kept.txt", "new.txt"), names(index.find(".txt", 10)).sorted()) + assertEquals("the stalest volume decides", 500L, index.indexedAt()) + } + + @Test + fun `a half-finished pass leaves the old timestamp in place`() = runBlocking { + val index = index() + index.filled("a.txt") + + index.beginVolume(volume) + index.add(listOf(file("b.txt"))) + + // Rows have arrived, but the pass has not finished: claiming the volume is fresh now + // would be the one lie an index must not tell. + assertEquals(1000L, index.indexedAt()) + } + + @Test + fun `forgetting a row takes it out of the results`() = runBlocking { + val index = index() + index.filled("a.txt", "b.txt") + + index.forget(StoragePath.of("$volume/a.txt")) + + assertEquals(listOf("b.txt"), names(index.find(".txt", 10))) + } + + @Test + fun `clearing empties the rows and the timestamps`() = runBlocking { + val index = index() + index.filled("a.txt") + + index.clear() + + assertTrue(index.find("a", 10).isEmpty()) + assertNull(index.indexedAt()) + } + + @Test + fun `nothing indexed yet reports no age at all`() = runBlocking { + assertNull(index().indexedAt()) + } + + @Test + fun `a schema bump with no migration reports itself`() { + val db = scratchDatabase("search-migration") + SqliteSearchIndex.create(db) + + val failure = runCatching { SqliteSearchIndex.upgrade(db, 1, 2) } + + // Unlike the trash index this one *may* be dropped and rebuilt, but not by accident: + // a bump with no written step is a bug to be reported. + assertTrue("expected the missing step to be reported", failure.isFailure) + } + + /** A throwaway database, so a migration can be driven without disturbing the real index. */ + private fun scratchDatabase(name: String): SQLiteDatabase = + SQLiteDatabase.openOrCreateDatabase( + File(RuntimeEnvironment.getApplication().cacheDir, "$name.db").apply { delete() }, + null, + ) +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/ArchiveEngineTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/ArchiveEngineTest.kt index be1fcc5..4f34980 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/domain/ArchiveEngineTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/ArchiveEngineTest.kt @@ -13,9 +13,11 @@ import org.apache.commons.compress.compressors.xz.XZCompressorOutputStream import org.apache.commons.compress.utils.SeekableInMemoryByteChannel import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import java.io.ByteArrayOutputStream +import java.io.File import java.io.OutputStream import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -268,6 +270,249 @@ class ArchiveEngineTest { assertFalse(result.succeeded) } + /* + * Listing is the preview: what the archive holds, without writing a byte of it. + */ + + @Test + fun `listing reads names, sizes and nesting without extracting anything`() = runBlocking { + val storage = storage() + ArchiveEngine(storage).createZip(listOf(src.child("folder"), src.child("b.txt")), zip) + + val entries = ArchiveEngine(storage).list(zip) + + assertEquals(setOf("folder/a.txt", "b.txt"), entries.map { it.name }.toSet()) + assertFalse("nothing was written — that is the point of a preview", storage.contains(out.child("b.txt"))) + + val nested = entries.single { it.name == "folder/a.txt" } + assertEquals("a.txt", nested.leafName) + assertEquals(1, nested.depth) + assertEquals("alpha".length.toLong(), nested.sizeBytes) + + assertEquals("b.txt", entries.single { it.name == "b.txt" }.leafName) + assertEquals(0, entries.single { it.name == "b.txt" }.depth) + } + + @Test + fun `a listing is the same when the backend cannot seek`() = runBlocking { + // The SAF fallback has no channel to offer, which drops ZIP onto its streaming + // path: same answer, slower, because the reader walks the compressed data. + val seeking = storage() + ArchiveEngine(seeking).createZip(listOf(src.child("folder"), src.child("b.txt")), zip) + + val streaming = storage().apply { seekableSupported = false } + ArchiveEngine(streaming).createZip(listOf(src.child("folder"), src.child("b.txt")), zip) + + assertEquals( + ArchiveEngine(seeking).list(zip).map { it.name }, + ArchiveEngine(streaming).list(zip).map { it.name }, + ) + } + + @Test + fun `a 7z is listed from its index`() = runBlocking { + val storage = storage() + val archive = StoragePath.of("/src/photos.7z") + storage.putBytes(archive, sevenZip("b.txt" to "beta", "folder/a.txt" to "alpha")) + + val entries = ArchiveEngine(storage).list(archive) + + assertEquals(listOf("b.txt", "folder/a.txt"), entries.map { it.name }) + assertEquals("beta".length.toLong(), entries.first().sizeBytes) + assertEquals(1, entries.last().depth) + } + + @Test + fun `a compressed file is listed as the one name inside it`() = runBlocking { + val storage = storage() + val archive = StoragePath.of("/src/notes.txt.gz") + storage.putBytes( + archive, + ByteArrayOutputStream().also { bytes -> + GzipCompressorOutputStream(bytes).use { it.write("hello gzip".toByteArray()) } + }.toByteArray(), + ) + + val entries = ArchiveEngine(storage).list(archive) + + assertEquals(listOf("notes.txt"), entries.map { it.name }) + // A wrapper stores neither a name nor a size, so a size here would be a guess. + assertNull(entries.single().sizeBytes) + assertFalse(entries.single().isDirectory) + } + + @Test + fun `an unsupported archive cannot be listed either`() = runBlocking { + val storage = storage() + val archive = StoragePath.of("/src/pack.rar") + storage.addFile(archive, "not really a rar") + + // The caller turns this into a message; here it only has to fail rather than + // pretend the archive is empty. + val listing = runCatching { ArchiveEngine(storage).list(archive) } + + assertTrue(listing.isFailure) + } + + /* + * Extracting one row of a listing, which is what tapping an entry in the preview does. + */ + + @Test + fun `one entry can be extracted without unpacking the rest`() = runBlocking { + val storage = storage() + ArchiveEngine(storage).createZip(listOf(src.child("folder"), src.child("b.txt")), zip) + val wanted = ArchiveEngine(storage).list(zip).single { it.name == "folder/a.txt" } + + val result = ArchiveEngine(storage).extractEntry(zip, wanted, out) + + assertTrue(result.succeeded) + assertEquals(1, result.completed) + assertEquals("alpha", storage.contentOf(out.child("folder/a.txt"))) + assertFalse("the rest stayed in the archive", storage.contains(out.child("b.txt"))) + } + + @Test + fun `one entry can be extracted when the backend cannot seek`() = runBlocking { + // No channel, so this goes through the streaming reader — the path where the + // reader has to walk past every entry in front of the wanted one. + val storage = storage().apply { seekableSupported = false } + ArchiveEngine(storage).createZip(listOf(src.child("folder"), src.child("b.txt")), zip) + val wanted = ArchiveEngine(storage).list(zip).single { it.name == "b.txt" } + + val result = ArchiveEngine(storage).extractEntry(zip, wanted, out) + + assertTrue(result.succeeded) + assertEquals("beta", storage.contentOf(out.child("b.txt"))) + assertFalse(storage.contains(out.child("folder/a.txt"))) + } + + @Test + fun `one entry can be extracted from a 7z`() = runBlocking { + val storage = storage() + val archive = StoragePath.of("/src/photos.7z") + storage.putBytes(archive, sevenZip("b.txt" to "beta", "folder/a.txt" to "alpha")) + // Deliberately the second entry: 7z has to step over the first to reach it. + val wanted = ArchiveEngine(storage).list(archive).single { it.name == "folder/a.txt" } + + val result = ArchiveEngine(storage).extractEntry(archive, wanted, out) + + assertTrue(result.succeeded) + assertEquals("alpha", storage.contentOf(out.child("folder/a.txt"))) + assertFalse(storage.contains(out.child("b.txt"))) + } + + @Test + fun `extracting a single hostile entry is refused`() = runBlocking { + val storage = storage() + val archive = StoragePath.of("/src/crafted.7z") + storage.putBytes(archive, sevenZip("../evil.txt" to "pwned", "good.txt" to "fine")) + // The listing shows what the archive really holds, hostile name and all — hiding it + // would hide the reason nothing can be extracted from that row. + val hostile = ArchiveEngine(storage).list(archive).single { it.name == "../evil.txt" } + + val result = ArchiveEngine(storage).extractEntry(archive, hostile, out) + + assertFalse(result.succeeded) + assertFalse(storage.contains(StoragePath.of("/evil.txt"))) + assertFalse(storage.contains(out.child("evil.txt"))) + } + + /* + * Reading one entry, which is what a preview of something inside an archive needs: the + * bytes, and nothing written anywhere. + */ + + @Test + fun `an entry can be read without extracting anything`() = runBlocking { + val storage = storage() + ArchiveEngine(storage).createZip(listOf(src.child("folder"), src.child("b.txt")), zip) + val wanted = ArchiveEngine(storage).list(zip).single { it.name == "folder/a.txt" } + + val bytes = ArchiveEngine(storage).readEntry(zip, wanted) + + assertEquals("alpha", bytes?.decodeToString()) + assertFalse("a preview must not write", storage.contains(out.child("folder/a.txt"))) + } + + @Test + fun `an entry can be read when the backend cannot seek`() = runBlocking { + // No channel, so the reader has to step over the entry in front of the wanted one. + val storage = storage().apply { seekableSupported = false } + ArchiveEngine(storage).createZip(listOf(src.child("folder"), src.child("b.txt")), zip) + + val bytes = ArchiveEngine(storage).readEntry(zip, ArchiveEngine(storage).list(zip).single { it.name == "b.txt" }) + + assertEquals("beta", bytes?.decodeToString()) + } + + @Test + fun `an entry can be read from a 7z, which has no stream per entry`() = runBlocking { + val storage = storage() + val archive = StoragePath.of("/src/photos.7z") + storage.putBytes(archive, sevenZip("b.txt" to "beta", "folder/a.txt" to "alpha")) + val wanted = ArchiveEngine(storage).list(archive).single { it.name == "folder/a.txt" } + + val bytes = ArchiveEngine(storage).readEntry(archive, wanted) + + assertEquals("alpha", bytes?.decodeToString()) + } + + @Test + fun `an entry inside a compressed wrapper is read under the name its archive implies`() = runBlocking { + val storage = storage() + val archive = StoragePath.of("/src/notes.txt.gz") + storage.putBytes( + archive, + ByteArrayOutputStream().also { bytes -> + GzipCompressorOutputStream(bytes).use { it.write("hello gzip".toByteArray()) } + }.toByteArray(), + ) + + val entries = ArchiveEngine(storage).list(archive) + assertEquals("hello gzip", ArchiveEngine(storage).readEntry(archive, entries.single())?.decodeToString()) + // The wrapper holds one entry and no name of its own, so a different name is nothing. + val wrong = entries.single().copy(name = "other.txt") + assertNull(ArchiveEngine(storage).readEntry(archive, wrong)) + } + + @Test + fun `reading refuses to hand out more than the limit`() = runBlocking { + // A compression wrapper can inflate a small file enormously, so a preview caps what + // it will accept rather than trusting the size the index claims. + val storage = storage() + ArchiveEngine(storage).createZip(listOf(src.child("b.txt")), zip) + val wanted = ArchiveEngine(storage).list(zip).single { it.name == "b.txt" } + + val read = runCatching { ArchiveEngine(storage).readEntry(zip, wanted, limit = 2) } + + assertTrue(read.isFailure) + } + + @Test + fun `an entry can be written straight to a file outside the backend`() = runBlocking { + val storage = storage() + ArchiveEngine(storage).createZip(listOf(src.child("b.txt")), zip) + val wanted = ArchiveEngine(storage).list(zip).single { it.name == "b.txt" } + val file = File.createTempFile("archive-entry", ".txt") + file.deleteOnExit() + + val written = ArchiveEngine(storage).extractEntryToFile(zip, wanted, file) + + assertTrue(written) + assertEquals("beta", file.readText()) + assertFalse("nothing was written through the backend", storage.contains(out.child("b.txt"))) + assertTrue("the copy is removed again", file.delete()) + } + + @Test + fun `reading an entry the archive does not hold is null`() = runBlocking { + val storage = storage() + ArchiveEngine(storage).createZip(listOf(src.child("b.txt")), zip) + + assertNull(ArchiveEngine(storage).readEntry(zip, ArchiveEntry("ghost.txt", false, 1))) + } + /** * A genuine 7z, built in memory rather than hand-written as bytes. * diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/SearchIndexerTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/SearchIndexerTest.kt new file mode 100644 index 0000000..a91050b --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/SearchIndexerTest.kt @@ -0,0 +1,110 @@ +package de.softwareapp_hb.filemanager.domain + +import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.domain.testutil.InMemorySearchIndex +import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The walk that fills the index. It is the same shape as the live search, so what is worth + * pinning is where the two *differ*: no cap, hidden entries skipped unless asked for, rows + * handed over in batches, and a volume's timestamp written only once the pass has finished. + */ +class SearchIndexerTest { + + private val volume = StoragePath.of("/storage/emulated/0") + + private fun storage() = InMemoryStorageSource().apply { addDirectory(volume) } + + @Test + fun `a walk records every file under the volume`() = runBlocking { + val storage = storage().apply { + addDirectory(volume.child("Documents")) + addFile(volume.child("top.txt"), "top") + addFile(volume.child("Documents/report.pdf"), "report") + } + val index = InMemorySearchIndex() + + val count = SearchIndexer(storage, index).index(volume, showHidden = false) + + assertEquals(3, count) + assertEquals(listOf("report.pdf"), index.find("report", 10).map { it.name }) + + val row = index.find("top", 10).single() + assertEquals(volume.child("top.txt"), row.path) + assertEquals("top.txt", row.name) + assertEquals("top".length.toLong(), row.sizeBytes) + } + + @Test + fun `a hidden folder is skipped whole when the preference says so`() = runBlocking { + val storage = storage().apply { + addDirectory(volume.child(".hidden")) + addFile(volume.child(".hidden/secret.txt"), "x") + addFile(volume.child(".nomedia"), "x") + addFile(volume.child("visible.txt"), "x") + } + + val hidden = SearchIndexer(storage, InMemorySearchIndex()).index(volume, showHidden = false) + val shown = SearchIndexer(storage, InMemorySearchIndex()).index(volume, showHidden = true) + + // The index must not know more than the app is willing to show. Folders count as rows + // in their own right, which is why the shown pass finds four: the folder itself, the + // file inside it, the hidden file and the visible one. + assertEquals(1, hidden) + assertEquals(4, shown) + } + + @Test + fun `rows are handed over in batches rather than one write per file`() = runBlocking { + val storage = storage().apply { + repeat(1200) { addFile(volume.child("file-$it.txt"), "x") } + } + val index = InMemorySearchIndex() + + val count = SearchIndexer(storage, index).index(volume, showHidden = false) + + assertEquals(1200, count) + assertEquals(listOf(500, 500, 200), index.addedBatches) + } + + @Test + fun `progress is reported before the first batch is written`() = runBlocking { + val storage = storage().apply { repeat(120) { addFile(volume.child("f$it.txt"), "x") } } + val index = InMemorySearchIndex() + val seen = mutableListOf() + + SearchIndexer(storage, index).index(volume, showHidden = false) { seen += it } + + // 120 files is under one batch, so without a separate report the row would read + // "0 files" until the entire pass had finished. + assertTrue("expected progress below the batch size", seen.any { it in 1 until 120 }) + assertEquals(listOf(120), index.addedBatches) + } + + @Test + fun `the timestamp is stamped from the clock when the pass finishes`() = runBlocking { + val index = InMemorySearchIndex() + + SearchIndexer(storage(), index, clock = { 4242L }).index(volume, showHidden = false) + + assertEquals(4242L, index.indexedAt()) + } + + @Test + fun `a second pass replaces the first rather than doubling it`() = runBlocking { + val storage = storage().apply { addFile(volume.child("first.txt"), "x") } + val index = InMemorySearchIndex() + SearchIndexer(storage, index).index(volume, showHidden = false) + + storage.delete(volume.child("first.txt")) + storage.addFile(volume.child("second.txt"), "x") + SearchIndexer(storage, index).index(volume, showHidden = false) + + assertTrue("what has gone is not left behind", index.find("first", 10).isEmpty()) + assertEquals(listOf("second.txt"), index.find(".txt", 10).map { it.name }) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/TrashTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/TrashTest.kt index 82fa816..4496ca7 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/domain/TrashTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/TrashTest.kt @@ -245,6 +245,68 @@ class TrashTest { assertTrue(index.records().isEmpty()) } + @Test + fun `what is offered back carries the name, the type, the size and where it came from`() = + runBlocking { + // This is what a trash screen has to show per row, and it must come from the + // index: reading it back off the filesystem would `stat` every staged file on + // every open. + val storage = storage() + val index = InMemoryTrashIndex() + trash(storage, index = index).stage(listOf(notes, docs)) + + val entries = trash(storage, index = index).restorable().single().entries + .associateBy { it.name } + + val file = entries.getValue("notes.txt") + assertEquals(notes, file.original) + assertFalse(file.isDirectory) + assertEquals(5L, file.sizeBytes) + + val folder = entries.getValue("Documents") + assertEquals(docs, folder.original) + assertTrue(folder.isDirectory) + } + + @Test + fun `one item can be put back without disturbing the rest of its batch`() = runBlocking { + val storage = storage() + val index = InMemoryTrashIndex() + val (batch, _) = trash(storage, index = index).stage(listOf(notes, docs)) + val single = batch!!.entries.single { it.name == "notes.txt" } + + val result = trash(storage, index = index).restore(single, batch) + + assertTrue(result.succeeded) + assertEquals(1, result.completed) + assertTrue(storage.contains(notes)) + assertFalse("the folder is still in the trash", storage.contains(docs)) + // The index may still carry the restored row — it is only tidied when the whole + // batch is gone — but the trash no longer offers it, because its file has gone. + val left = trash(storage, index = index).restorable() + assertEquals(listOf(docs), left.single().entries.map { it.original }) + } + + @Test + fun `a batch is forgotten once its last item is back`() = runBlocking { + val storage = storage() + val index = InMemoryTrashIndex() + val (batch, _) = trash(storage, index = index).stage(listOf(notes, docs)) + + trash(storage, index = index).restore(batch!!.entries.single { it.name == "notes.txt" }, batch) + assertEquals( + "the rest of the batch is still offered back", + 1, + trash(storage, index = index).restorable().size, + ) + + trash(storage, index = index).restore(batch.entries.single { it.name == "Documents" }, batch) + + assertTrue(index.records().isEmpty()) + assertTrue(storage.contains(notes)) + assertTrue(storage.contains(docs)) + } + private companion object { const val TRASHED_AT = 1_700_000_000_000L } diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemorySearchIndex.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemorySearchIndex.kt new file mode 100644 index 0000000..3c49566 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemorySearchIndex.kt @@ -0,0 +1,54 @@ +package de.softwareapp_hb.filemanager.domain.testutil + +import de.softwareapp_hb.filemanager.core.NameMatcher +import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.data.IndexedFile +import de.softwareapp_hb.filemanager.data.SearchIndex + +/** + * A [SearchIndex] in a map, for exercising the walk and the search without a database. + * + * It matches with [NameMatcher] — the app's own matcher — where the real one narrows with + * SQL. A test that passes here therefore says something about the app's *rules* and nothing + * at all about its SQL, which is what [de.softwareapp_hb.filemanager.data.SqliteSearchIndexTest] + * is for. + * + * [addedBatches] records the size of every write, so a test can prove that rows are handed + * over in batches rather than one transaction per file. + */ +class InMemorySearchIndex : SearchIndex { + + private val rows = LinkedHashMap() + private val indexedAt = mutableMapOf() + + val addedBatches: MutableList = mutableListOf() + + override suspend fun beginVolume(volume: String) { + rows.filterValues { it.volume == volume }.keys.toList().forEach(rows::remove) + } + + override suspend fun add(rows: List) { + addedBatches += rows.size + for (row in rows) this.rows[row.path.value] = row + } + + override suspend fun endVolume(volume: String, indexedAtMillis: Long) { + indexedAt[volume] = indexedAtMillis + } + + override suspend fun find(query: String, limit: Int): List = rows.values + .filter { NameMatcher.matches(it.name, query) } + .sortedBy { it.name } + .take(limit) + + override suspend fun forget(path: StoragePath) { + rows.remove(path.value) + } + + override suspend fun indexedAt(): Long? = indexedAt.values.minOrNull() + + override suspend fun clear() { + rows.clear() + indexedAt.clear() + } +} 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 a404640..ca848cf 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 @@ -5,18 +5,26 @@ import android.content.Intent import android.net.Uri import androidx.activity.result.IntentSenderRequest import androidx.lifecycle.SavedStateHandle +import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.core.ConflictPolicy import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.SortDirection import de.softwareapp_hb.filemanager.core.SortKey import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.data.ApkInfo +import de.softwareapp_hb.filemanager.data.ApkInspector import de.softwareapp_hb.filemanager.data.DeleteRequestFactory import de.softwareapp_hb.filemanager.data.IncomingSource +import de.softwareapp_hb.filemanager.data.IndexedFile +import de.softwareapp_hb.filemanager.data.SearchIndex import de.softwareapp_hb.filemanager.data.StorageAccessMode import de.softwareapp_hb.filemanager.data.VolumeInfo +import de.softwareapp_hb.filemanager.domain.ArchiveEngine +import de.softwareapp_hb.filemanager.domain.ArchiveEntry import de.softwareapp_hb.filemanager.domain.OperationKind import de.softwareapp_hb.filemanager.domain.OperationNotifier import de.softwareapp_hb.filemanager.domain.OperationProgress +import de.softwareapp_hb.filemanager.domain.testutil.InMemorySearchIndex import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource import de.softwareapp_hb.filemanager.ui.browser.testutil.FakeStorageLocation import kotlinx.coroutines.Dispatchers @@ -37,7 +45,11 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream +import java.io.File import java.io.OutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -110,7 +122,31 @@ class BrowserViewModelTest { deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null }, savedState: SavedStateHandle = SavedStateHandle(), incoming: IncomingSource? = fakeIncoming, - ) = BrowserViewModel(location, recordingNotifier, deleteRequests, savedState, incoming) + apkInspector: ApkInspector = ApkInspector.None, + scratchRoot: File? = null, + searchIndex: SearchIndex = SearchIndex.None, + ) = BrowserViewModel( + storage = location, + notifier = recordingNotifier, + deleteRequests = deleteRequests, + savedState = savedState, + incoming = incoming, + apkInspector = apkInspector, + searchIndex = searchIndex, + scratchRoot = scratchRoot, + ) + + /** A real ZIP in memory, so an entry can be previewed out of something that has an index. */ + private fun zipOf(vararg entries: Pair): ByteArray = + ByteArrayOutputStream().also { bytes -> + ZipOutputStream(bytes).use { zip -> + for ((name, body) in entries) { + zip.putNextEntry(ZipEntry(name)) + zip.write(body) + zip.closeEntry() + } + } + }.toByteArray() @Test fun `starts on the root list`() { @@ -190,6 +226,364 @@ class BrowserViewModelTest { assertFalse(model.state.value.isSelecting) } + @Test + fun `a file with no preview of its own is handed to the system`() { + val model = viewModel() + // A Word document: something this app has no way to draw, and which another app + // almost certainly can. + storage.addFile(downloads.child("report.docx"), "document") + model.openDirectory(downloads) + + val document = model.state.value.entries.single { it.name == "report.docx" } + model.open(document) + + assertEquals(document, model.state.value.pendingOpen) + + // The screen clears the signal once the chooser is up; if it did not, the same + // entry would be launched again on the next recomposition. + model.consumeOpen() + assertNull(model.state.value.pendingOpen) + } + + @Test + fun `an image still opens in the built-in viewer, but can also be handed on`() { + val model = viewModel() + storage.addFile(downloads.child("pic.png"), "image") + model.openDirectory(downloads) + + val image = model.state.value.entries.single { it.name == "pic.png" } + model.open(image) + assertEquals(image, (model.state.value.overlay as Overlay.Image).entry) + + // The tap default is the app's own viewer; "open with" is the deliberate way past it. + model.openWith(image) + assertEquals(image, model.state.value.pendingOpen) + } + + @Test + fun `the SAF fallback will not hand out a file, and says why`() { + val model = viewModel() + storage.addFile(downloads.child("report.docx"), "document") + model.openDirectory(downloads) + + // Paths in the fallback are relative to the granted tree while still looking + // absolute, so a chooser would be handed a URI pointing at nothing. + location.setMode(StorageAccessMode.SAF_FALLBACK, needsAllFiles = false) + model.refresh() + + model.open(model.state.value.entries.single { it.name == "report.docx" }) + + assertNull(model.state.value.pendingOpen) + assertEquals(R.string.message_open_needs_full_access, model.state.value.message?.textRes) + } + + /* + * Which preview a tap reaches, per kind of file. The viewers themselves need a device — + * a renderer, a decoder, a web engine — so what is asserted here is the routing: the + * thing that was wrong before, and the thing that can be checked without one. + */ + + @Test + fun `a zip opens its contents instead of unpacking them`() { + val model = viewModel() + val archive = downloads.child("photos.zip") + runBlocking { + storage.addFile(downloads.child("note.txt"), "hello") + ArchiveEngine(storage).createZip(listOf(downloads.child("note.txt")), archive) + } + model.openDirectory(downloads) + + model.open(model.state.value.entries.single { it.name == "photos.zip" }) + + val overlay = model.state.value.overlay as Overlay.Archive + assertEquals(listOf("note.txt"), overlay.entries.map { it.name }) + assertFalse("a preview must not write anything", storage.contains(downloads.child("note.txt.copy"))) + } + + @Test + fun `an archive that cannot be read closes again and says so`() { + val model = viewModel() + storage.addFile(downloads.child("broken.zip"), "not a zip at all") + model.openDirectory(downloads) + + model.open(model.state.value.entries.single { it.name == "broken.zip" }) + + assertEquals(Overlay.None, model.state.value.overlay) + assertEquals(R.string.message_archive_unreadable, model.state.value.message?.textRes) + } + + @Test + fun `documents and media open in their own viewers`() { + val model = viewModel() + storage.addFile(downloads.child("report.pdf"), "%PDF-1.4") + storage.addFile(downloads.child("clip.mp4"), "video") + storage.addFile(downloads.child("song.mp3"), "audio") + model.openDirectory(downloads) + + fun openOverlay(name: String): Overlay { + model.open(model.state.value.entries.single { it.name == name }) + return model.state.value.overlay + } + + assertTrue(openOverlay("report.pdf") is Overlay.Pdf) + assertTrue(openOverlay("clip.mp4") is Overlay.Media) + assertTrue(openOverlay("song.mp3") is Overlay.Media) + } + + @Test + fun `markup is drawn, and the text editor stays one action away`() { + val model = viewModel() + storage.addFile(downloads.child("page.html"), "

hi

") + storage.addFile(downloads.child("logo.svg"), "") + model.openDirectory(downloads) + + val page = model.state.value.entries.single { it.name == "page.html" } + model.open(page) + + // Rendered rather than edited, even though `html` is a text extension. + assertEquals("

hi

", (model.state.value.overlay as Overlay.Markup).markup) + + model.editText(page) + assertEquals(page, (model.state.value.overlay as Overlay.Text).entry) + + // An SVG is classified as an image, and the raster viewer cannot decode one. + model.open(model.state.value.entries.single { it.name == "logo.svg" }) + assertTrue(model.state.value.overlay is Overlay.Markup) + } + + @Test + fun `an apk is inspected rather than being handed to another app`() { + val info = ApkInfo( + label = "Example", + packageName = "com.example.app", + versionName = "1.2", + versionCode = 3, + minSdk = 26, + targetSdk = 36, + permissions = listOf("android.permission.INTERNET"), + ) + val model = viewModel(apkInspector = ApkInspector { info }) + storage.addFile(downloads.child("app.apk"), "not really an apk") + model.openDirectory(downloads) + + model.open(model.state.value.entries.single { it.name == "app.apk" }) + + val overlay = model.state.value.overlay as Overlay.Package + assertEquals(info, overlay.info) + assertFalse("the card is no longer waiting", overlay.loading) + } + + /* + * Previewing what is *inside* an archive, which is the other half of the archive preview: + * extracting is one action, showing is another. + */ + + @Test + fun `a text entry inside an archive is previewed without writing anything`() { + val model = viewModel() + val archive = downloads.child("notes.zip") + storage.putBytes(archive, zipOf("docs/readme.txt" to "hello from inside".toByteArray())) + model.openDirectory(downloads) + model.open(model.state.value.entries.single { it.name == "notes.zip" }) + + model.previewArchiveEntry(entryOf(model, "docs/readme.txt")) + + val overlay = model.state.value.overlay as Overlay.Text + assertEquals("hello from inside", overlay.content) + assertFalse("a copy has nothing of its own to save back to", overlay.editable) + assertFalse(storage.contains(downloads.child("docs/readme.txt"))) + } + + @Test + fun `markup inside an archive is rendered, and not offered for editing`() { + val model = viewModel() + val archive = downloads.child("site.zip") + storage.putBytes(archive, zipOf("page.html" to "

hi

".toByteArray())) + model.openDirectory(downloads) + model.open(model.state.value.entries.single { it.name == "site.zip" }) + + model.previewArchiveEntry(entryOf(model, "page.html")) + + val overlay = model.state.value.overlay as Overlay.Markup + assertEquals("

hi

", overlay.markup) + assertFalse(overlay.editable) + } + + @Test + fun `an image entry is copied into the scratch space, and the copy goes with the preview`() { + val scratch = File(System.getProperty("java.io.tmpdir"), "fm-preview-${System.nanoTime()}") + val model = viewModel(scratchRoot = scratch) + val archive = downloads.child("pics.zip") + storage.putBytes(archive, zipOf("shot.png" to "not really a png".toByteArray())) + model.openDirectory(downloads) + model.open(model.state.value.entries.single { it.name == "pics.zip" }) + + model.previewArchiveEntry(entryOf(model, "shot.png")) + + // A decoder cannot be handed a few bytes, so the viewer gets a real file — in the + // app's own cache, not in the folder the user is looking at. + val copy = File((model.state.value.overlay as Overlay.Image).entry.path.value) + assertTrue("the viewer is given a real file", copy.isFile) + assertEquals("not really a png", copy.readText()) + assertEquals("the copy answers to the name it had inside the archive", "shot.png", copy.name) + assertFalse(storage.contains(downloads.child("shot.png"))) + + model.closeOverlay() + + assertFalse("the copy does not outlive the preview", copy.exists()) + scratch.deleteRecursively() + } + + @Test + fun `a preview that would be too large is refused before anything is read`() { + val model = viewModel() + val archive = downloads.child("big.zip") + storage.putBytes(archive, zipOf("huge.png" to "x".toByteArray())) + model.openDirectory(downloads) + model.open(model.state.value.entries.single { it.name == "big.zip" }) + val huge = entryOf(model, "huge.png").copy(sizeBytes = BrowserViewModel.PREVIEW_MAX_BYTES + 1) + + model.previewArchiveEntry(huge) + + assertTrue("the archive is still what is on screen", model.state.value.overlay is Overlay.Archive) + assertEquals(R.string.message_preview_too_large, model.state.value.message?.textRes) + } + + @Test + fun `an entry with no viewer is offered an extraction instead`() { + val model = viewModel() + val archive = downloads.child("docs.zip") + storage.putBytes(archive, zipOf("report.docx" to "not really a docx".toByteArray())) + model.openDirectory(downloads) + model.open(model.state.value.entries.single { it.name == "docs.zip" }) + + model.previewArchiveEntry(entryOf(model, "report.docx")) + + assertTrue(model.state.value.overlay is Overlay.Archive) + assertEquals(R.string.message_preview_needs_extract, model.state.value.message?.textRes) + } + + @Test + fun `closing a preview of an entry returns to the archive it came from`() { + val model = viewModel() + val archive = downloads.child("notes.zip") + storage.putBytes(archive, zipOf("docs/readme.txt" to "hello".toByteArray())) + model.openDirectory(downloads) + model.open(model.state.value.entries.single { it.name == "notes.zip" }) + + model.previewArchiveEntry(entryOf(model, "docs/readme.txt")) + assertTrue(model.state.value.overlay is Overlay.Text) + + model.closeOverlay() + + // Reading a file inside a zip and closing it must not throw the user out of the zip. + assertTrue(model.state.value.overlay is Overlay.Archive) + } + + /** One row of the archive the preview is showing, by the name the archive stores it under. */ + private fun entryOf(model: BrowserViewModel, name: String): ArchiveEntry = + (model.state.value.overlay as Overlay.Archive).entries.single { it.name == name } + + /* + * Searching everywhere: answered by the index rather than by a walk, and honest about + * both what it found and what it is. + */ + + @Test + fun `searching everywhere answers from the index, across folders`() { + val index = InMemorySearchIndex() + // Not under the folder being browsed, so no walk from there could ever find it. + storage.addFile(root.child("holiday.png"), "x") + runBlocking { + index.beginVolume(root.value) + index.add( + listOf( + indexed(root.child("holiday.png"), "holiday.png"), + // Indexed, but not on the storage: the row has stopped being useful. + indexed(root.child("ghost.png"), "ghost.png"), + ), + ) + index.endVolume(root.value, System.currentTimeMillis()) + } + + val model = viewModel(searchIndex = index) + model.openDirectory(downloads) + model.setSearchScope(SearchScope.EVERYWHERE) + + model.setQuery("png") + + assertEquals(listOf("holiday.png"), model.state.value.entries.map { it.name }) + assertTrue( + "the row that lied is forgotten, not repeated", + runBlocking { index.find("ghost", 10) }.isEmpty(), + ) + } + + @Test + fun `switching scope re-answers the search rather than leaving the old results`() { + val index = InMemorySearchIndex() + storage.addFile(root.child("holiday.png"), "x") + runBlocking { + index.beginVolume(root.value) + index.add(listOf(indexed(root.child("holiday.png"), "holiday.png"))) + index.endVolume(root.value, System.currentTimeMillis()) + } + + val model = viewModel(searchIndex = index) + model.openDirectory(downloads) + model.setQuery("png") + assertTrue("the walk finds nothing under this folder", model.state.value.entries.isEmpty()) + + model.setSearchScope(SearchScope.EVERYWHERE) + + assertEquals(listOf("holiday.png"), model.state.value.entries.map { it.name }) + } + + @Test + fun `the index is rebuilt when it has never been built`() { + // No timestamps and no rows: searching everywhere has nothing to ask, so the pass + // that makes the search possible is run rather than explained away. + val index = InMemorySearchIndex() + val model = viewModel(searchIndex = index) + storage.addFile(downloads.child("holiday.png"), "x") + model.openDirectory(downloads) + model.setSearchScope(SearchScope.EVERYWHERE) + + model.setQuery("holiday") + + assertEquals(listOf("holiday.png"), model.state.value.entries.map { it.name }) + assertNotNull("the pass left a timestamp", runBlocking { index.indexedAt() }) + } + + private fun indexed(path: StoragePath, name: String) = IndexedFile( + volume = root.value, + path = path, + name = name, + isDirectory = false, + sizeBytes = 1L, + modifiedMillis = 0L, + ) + + @Test + fun `a preview that needs a real file is refused in the SAF fallback`() { + // The fallback browses a folder the user picked, whose paths merely look absolute: + // there is no descriptor to open, so a blank frame is what a preview would amount to. + val model = viewModel() + storage.addFile(downloads.child("clip.mp4"), "video") + storage.addFile(downloads.child("pic.png"), "image") + model.openDirectory(downloads) + location.setMode(StorageAccessMode.SAF_FALLBACK, needsAllFiles = false) + model.refresh() + + model.open(model.state.value.entries.single { it.name == "clip.mp4" }) + assertEquals(Overlay.None, model.state.value.overlay) + assertEquals(R.string.message_preview_needs_full_access, model.state.value.message?.textRes) + + model.open(model.state.value.entries.single { it.name == "pic.png" }) + assertEquals(Overlay.None, model.state.value.overlay) + assertEquals(R.string.message_preview_needs_full_access, model.state.value.message?.textRes) + } + @Test fun `createFolder makes a directory in the current folder`() { val model = viewModel() diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a5a36e1..db03528 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,7 +19,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | M7 | On-device validation & hardening | ✅ | Run on a real **Xiaomi M2101K7AG** (Android 12 / MIUI V140) — see [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). Verified on hardware: the first-run permission prompt, granting "All files access" while the app is alive (picked up on resume), browsing real storage, the text editor's read **and** write path (independently checked with `adb shell cat`), the storage-usage screen, landscape layout, and **graceful degradation when access is revoked mid-use**. Found and fixed two real defects: misleading first-run copy ("Full access was declined" on a device where nothing had been declined), and a usage screen that stayed blank for **~45–60 s** on a 55 GB volume — now one listing per directory instead of a `stat` per node, with rows streamed out as they are measured. **142 JVM tests** | | M8 | Localisation groundwork | ✅ | Every user-visible string is in `res/values/strings.xml` with real ``, and `text/TextRes.kt` maps **both** `OperationKind` and `FileKind` onto resources so the screen, the notification and the details sheet cannot drift. The audit found a genuine miss: the details sheet rendered `FileKind.name`, i.e. untranslatable English — now a resource. `isPseudoLocalesEnabled = true` on the debug build makes "did every string come from resources?" mechanically checkable; verified `en-rXA` is present in the debug APK and the flag is debug-only. Byte units and the timestamp pattern are deliberately unlocalised, and the README says why. **142 JVM tests** | | M9 | Monetisation | ✅ | **Decided: one-time paid — €4.49 base with a 14-day launch price of €2.29**, both set in the Play Console (*Products › App pricing*), so there is **no Billing client, no `com.android.vending.BILLING` permission and no purchase code**. Rationale, market anchors and Console steps: [MONETISATION](MONETISATION.md). Verified against the release APK: zero billing and zero `INTERNET` references. **The price must be set before the first upload** — Play allows paid → free but never free → paid | -| 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 2192/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 | +| 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 | ## Key decisions diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index 65ca869..8ff6647 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -88,7 +88,7 @@ subscription, and no network access at all. You buy it once and it is yours. BROWSE ANYTHING • Internal storage, SD cards and USB drives, side by side • List or grid view, with sorting by name, size, date or type -• Breadcrumb navigation and an in-folder search +• Breadcrumb navigation, and a search that can look in one folder or everywhere • Hidden files on request ANY SIZE OF SCREEN @@ -100,7 +100,8 @@ ANY SIZE OF SCREEN WORKS WITH YOUR OTHER APPS • Share a file to Plain Files from anywhere, then pick the folder to drop it in • Choose Plain Files when another app asks you for a file -• Search a whole folder tree, not just the folder you are looking at +• Search everywhere at once: an index means answers arrive right away instead of after a + long scan — and it always says how current it is • Delete, rename or move by mistake? Undo it MANAGE YOUR FILES @@ -115,6 +116,8 @@ FIND OUT WHERE THE SPACE WENT ARCHIVES • Create ZIP archives, and extract ZIP, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, BZ2, XZ and 7z +• Look inside an archive before you extract anything — text, images, documents, audio and + video included, read straight out of the file • RAR is not supported — it needs native code this app does not ship, and it says so rather than failing with a strange error @@ -148,6 +151,36 @@ the app's description** when it declares `MANAGE_EXTERNAL_STORAGE`. The "PERMISS EXPLAINED" section is not marketing copy — it is the evidence the Permissions Declaration Form asks for. +## Ad headlines (App campaign, 30 characters each) + +Google App campaigns take **up to five headlines**, and the system mixes and matches them, so +each one has to stand on its own rather than continue the one before it. Counts include spaces +and are measured, not estimated: + +| Headline | Chars | Angle | +|---|---|---| +| Ad-free file manager | 20 | The whole pitch, in three words | +| Pay once, keep it forever | 25 | One-time price, no subscription | +| Works offline. No network. | 26 | The privacy claim the APK actually backs | +| SD cards and USB drives | 23 | Reach beyond internal storage | +| See where your storage went | 27 | The storage-usage view | + +Spares, to swap in when an asset group wants a different emphasis: `Two panes on your tablet` +(24), `Undo a delete you regret` (24), `Built for phones and tablets` (28). + +Three rules these are written to: + +- **Never "free".** This is a paid app, so a headline implying otherwise is misleading as well + as a policy problem. The launch price is the only discount, and it belongs in the price field, + not in a headline. +- **No superlatives.** "Best", "top" and "#1" are unverifiable claims, and they are not how this + app sells anyway — the differentiator is what it *does not* contain. +- **Sentence case, no shouting.** No all-caps text, no emoji, no stacked punctuation; Google's + editorial policy refuses all three, and it matches the voice of the listing copy above. + +The app name is taken from the store listing (*App name* above) and is not repeated in a +headline. + ## Still to do before publishing - **Screenshots.** `tools/make-store-screenshots.sh` is written and the raw-screenshot