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