0743bda29e
The app bar of any folder now carries a star, and a starred folder appears in a Favourites section on the home screen, above the libraries. Above, because it is the user's own list - and absent entirely while empty, so someone opening the app for the first time still meets the libraries first, which is the order they were given for a reason. The star in the row is the removal, which is what a star means everywhere else: the mark that says "this is a favourite" is the thing you tap to stop it being one. That avoids a long-press as well, and with it a destructive gesture nobody can see. A favourite is a reference, not a copy: data/BookmarkStore keeps one path per row and nothing about the folder's contents, so reading the whole list is one query and no filesystem work, which is what makes it affordable on every visit to the home screen. It has its own SQLite database rather than a table in the trash index - one table and three queries do not justify Room in a build that has no annotation processor, and the two stores have nothing to say to each other. Two behaviours there are decisions rather than defaults. A folder that has gone is kept and marked (Folder not found) instead of being deleted: a card out of its slot and a folder deleted in another app are indistinguishable from here, and silently forgetting someone's list because their SD card was out is the worse of the two mistakes. And opening the row re-checks existence at that moment, because the flag is as old as the screen - so the answer is a message rather than an empty listing, which would read as an empty folder instead of a missing one. Two smaller refusals came out of the same question. There is no star in a library or on the root screen, because neither is a folder and there would be nothing to come back to: the distinction that governs the new-folder button, and one the view model enforces too rather than trusting a stale screen. And a bookmark has no undo, because Undo means "the last change to the filesystem the app made" and a favourite is not one - the star is one tap to put back. The row's label comes from the same place the breadcrumbs do, which is why a starred volume root reads as "Internal storage" rather than as "0". Sixteen tests: nine against real SQLite through Robolectric (round trip, ordering, starring twice neither duplicating nor moving a row, and the same no-drop migration guard the trash index carries), and seven through the view model (toggling, outliving the view model that added it, newest first, the missing folder kept and marked, the missing folder refusing to open, and a library refusing to be starred). 371 JVM tests, lint clean. Verified on the phone: the star appears only inside a folder and flips from "Add to favourites" to "Remove from favourites" without leaving the screen; the home screen lists the volume root above the libraries with its own label and its path underneath; the row opens it with the star already filled; and the row's star takes the whole section away with a snackbar. The documentation moved with it - CHANGELOG, ROADMAP (M15), README, COMPETITIVE_ANALYSIS (that gap closes instead of joining the candidates), EDGE_CASE_TESTING, and the generated code map.
98 lines
4.2 KiB
Kotlin
98 lines
4.2 KiB
Kotlin
package de.softwareapp_hb.filemanager
|
|
|
|
import android.app.Application
|
|
import android.content.Context
|
|
import coil3.ImageLoader
|
|
import coil3.SingletonImageLoader
|
|
import coil3.video.VideoFrameDecoder
|
|
import de.softwareapp_hb.filemanager.data.ApkInspector
|
|
import de.softwareapp_hb.filemanager.data.BookmarkStore
|
|
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.SqliteBookmarkStore
|
|
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
|
|
import de.softwareapp_hb.filemanager.domain.OperationNotifier
|
|
import de.softwareapp_hb.filemanager.service.ServiceOperationNotifier
|
|
|
|
/**
|
|
* Application entry point and the app's tiny object graph.
|
|
*
|
|
* There is no dependency-injection framework here on purpose: the long-lived collaborators below are
|
|
* built once and handed to the one ViewModel that needs them. Nothing holds a reference to an
|
|
* Activity, so none of this leaks.
|
|
*/
|
|
class FileManagerApplication : Application(), SingletonImageLoader.Factory {
|
|
|
|
/** Where files live and which backend is in effect. */
|
|
lateinit var storage: StorageProvider
|
|
private set
|
|
|
|
/** Keeps the foreground service in step with a running operation. */
|
|
lateinit var notifier: OperationNotifier
|
|
private set
|
|
|
|
/** Builds the platform's own delete confirmation, when it can. */
|
|
lateinit var deleteRequests: DeleteRequestFactory
|
|
private set
|
|
|
|
/** Reads whatever another app shares in. */
|
|
lateinit var incoming: IncomingSource
|
|
private set
|
|
|
|
/** Remembers what is in the trash, and where each item came from. */
|
|
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
|
|
|
|
/** Remembers the folders the user starred, so the home screen can offer them back. */
|
|
lateinit var bookmarkStore: BookmarkStore
|
|
private set
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
storage = StorageProvider(this)
|
|
notifier = ServiceOperationNotifier(this)
|
|
deleteRequests = MediaStoreDeleter(this).factory()
|
|
incoming = ContentResolverIncomingSource(contentResolver)
|
|
trashIndex = SqliteTrashIndex(this)
|
|
apkInspector = PackageManagerApkInspector(this)
|
|
searchIndex = SqliteSearchIndex(this)
|
|
bookmarkStore = SqliteBookmarkStore(this)
|
|
}
|
|
|
|
/**
|
|
* The image loader every thumbnail and viewer goes through.
|
|
*
|
|
* Built here for one reason: **video frames**. Coil has no idea what to do with an `.mp4` until a
|
|
* decoder is registered for it, and `coil-video` supplies one that understands the platform's own
|
|
* `MediaMetadataRetriever`.
|
|
*
|
|
* The decoder is registered **explicitly** rather than left to `coil-video`'s `META-INF/services`
|
|
* entry, because that entry does not survive a release build: R8 removes both the service file and
|
|
* the class it names, since a class mentioned only from a resource looks unused. Checked against
|
|
* the release APK, where neither was present — which would have left video thumbnails working in
|
|
* debug and silently showing an icon in the build everyone installs.
|
|
*
|
|
* Only the decoder is configured. Coil's own cache sizes are left alone deliberately: they are
|
|
* sized from the device's heap, which a number written here would only be guessing at.
|
|
*/
|
|
override fun newImageLoader(context: Context): ImageLoader =
|
|
ImageLoader.Builder(context)
|
|
.components { add(VideoFrameDecoder.Factory()) }
|
|
.build()
|
|
}
|