Common tasks¶
Short recipes for the changes that come up most. Each names the files and functions to touch, in order. App paths are
under app/src/main/java/tools/senko/materialdrain/; the other modules say so. For the reasons behind the pieces, see
Architecture, Hosts in code and The interface.
Adding a setting¶
A setting lives in AppSettings, is shown by the settings catalog, and reaches the screens either through a ViewModel
or through a CompositionLocal.
-
Store it in
settings/AppSettings.kt. Add a preference key at the top of the file, then a privatePrefmade bybooleanSetting(key, default)(orintSetting,enumSetting), a publicStateFlowwith a KDoc that says what it does and what the default is, and a setter:private const val SHOW_HIDDEN_PREF = "show_hidden" private val showHiddenPref = booleanSetting(SHOW_HIDDEN_PREF, false) /** Files whose names start with a dot are listed too. Off by default. */ val showHidden: StateFlow<Boolean> = showHiddenPref.flow fun setShowHidden(enabled: Boolean) = showHiddenPref.set(enabled)showHiddenis an example name, not a real setting. APrefwrites the preferences and its flow together, so the two never disagree. -
Show it in
settings/ui/SettingsCatalog.kt: add an item to theitemsof the rightSettingsCategory. A switch is aSettingsItem.Toggle;isCheckedruns while composing, so it collects the flow and stays up to date.infois the explanation behind its ⓘ, andchildrenare settings of its own, indented under it and out of reach while it's off:SettingsItem.Toggle( title = "Show hidden files", summary = "Names starting with a dot", isChecked = { appSettings.showHidden.collectAsState().value }, onCheckedChange = { appSettings.setShowHidden(it) } )A few options side by side (segmented buttons) are a
SettingsItem.Choice, like Search result limit; a button is aSettingsItem.Action, like Try it; a heading inside a page is aSettingsItem.Header, as Look and feel's are. Anything else is aSettingsItem.Customwith a composable of its own, like theThemeColorPicker. Keep the texts short: a title of a few words, a summary of one line. -
A new category is a new
SettingsCategory(id, title, summary, icon, tone, items)in theSettingsCataloglist, in the order it should appear, with astatusfor the end of its tile if it has one. Nothing else needs to change:SettingsScreen.ktrenders every category and item from the catalog. LeavehasSaveButtonfalse; only the Account's API key is saved with the button. -
If an item needs something the settings don't have (another ViewModel, say), add it to
SettingsEnvironmentinsettings/ui/SettingsModel.ktand pass it whereSettingsScreenContentbuilds the environment. -
Make it reach the screen, one of two ways:
- A ViewModel reads it. The ViewModels get
AppSettingsfromViewModelFactory; collect the flow ininit, asFilesystemViewModeldoes withhideSearchIndex, orcombineit, as the sort order is. - A composable deep in the tree reads it. Add a
CompositionLocaltoui/LocalSettings.ktwith its default and a KDoc. InMaterialdrainScreen()(App.kt), collect the flow and addLocalShowHidden provides showHiddento theCompositionLocalProvider. Read it withLocalShowHidden.current. This is howLocalBlurredBackdropandLocalTextWrapwork; settings read together go in one class, asLocalPlayerSettings(Media) andLocalListLook(the file lists' look) do. A screen that already getsappSettings(likeBrowserScreen) can just collect the flow itself.
- A ViewModel reads it. The ViewModels get
-
Document it in the website's
docs/user-guide/settings.md, marked<span class="nightly" title="Only in the Nightly build so far, not in a stable release yet">Nightly</span>until it's in a stable release.
Adding a menu or a menu item¶
Every menu is an AppMenu (ui/components/AppMenu.kt); see AppMenu for why. Don't use
Material's DropdownMenu, and don't restyle one menu on its own: change AppMenuDefaults and every menu follows.
A new ⋮ menu:
- Use
OverflowMenuButton(contentDescription = "More options for …") { close -> … }. - Add one
AppMenuItem(text, leadingIcon = …, onClick = { close(); doIt() })per entry. Close first, then act. - Gate entries on what the host and the screen allow (
if (canDelete) …), asFilesystemMenudoes with itscanWriteandcanDelete. - Put a destructive entry last, after an
AppMenuDivider(), withdestructive = true.
A menu on a button of your own:
- Wrap the button in a
Boxthat holds only the button; the menu measures that box to place itself. - Keep
var expanded by remember { mutableStateOf(false) }; the button sets it totrue. - In the same
Box, callAppMenu(expanded = expanded, onDismiss = { expanded = false }) { … }. - Pick
side(MenuSide.Below,Above,Auto) andedge(MenuEdge.Start,Center,End,Auto) when the defaults don't fit, andmatchAnchorWidth = truefor a menu under a wide card. - For a choice that should stay visible (a sort field, a speed), mark the current one
active = trueand don't close the menu inonClick. - For a second level, wrap the entries in
AppMenuPages(showSecondary, primary = { … }, secondary = { … }), and start the secondary page with an entry going back, asVideoSettingsMenuinui/media/VideoPlayer.ktdoes.
An item in an existing menu:
| Menu | Where |
|---|---|
| A file of the Files screen or of a list | File in files/ |
| A file or folder of the Filesystem | Filesystem in browser/ |
| A list in the lists overview | List in browser/ |
| A file's details | Their own buttons: Action in files/ (they have no ⋮) |
| The selection bar | A Selection in selection in Browser (the first three are icons, the rest the menu) |
| The ⋮ at the top of a screen | A Screen in screen in Browser: Screen for what's done with the page, NEW for New (see The ⋮ menu of a screen) |
| The host switcher | Host in hosts/ |
Adding a REST endpoint name¶
A generic_rest config's endpoints are looked up by name. A new name has to be known by the provider (which calls it),
by the config editor (which offers it), and by the documentation.
- Name it in
provider-generic-rest/…/GenericRestStorageProvider.kt: addprivate const val ENDPOINT_FOO = "foo"next to the others. Names are lowercase with underscores; most that work on a path start withbrowse_. -
Call it where the operation is implemented. Look the endpoint up, and say so when the config has none:
val endpoint = config.endpoints[ENDPOINT_BROWSE_MKDIR] ?: return ApiResponse.Error(ProviderError("not_configured", "This host has no mkdir endpoint configured.")) val placeholders = mapOf("path" to path.trim('/'), "action" to if (makeParents) "mkdirall" else "mkdir") val result = buffered(endpoint, placeholders) ?: return ApiResponse.Error(ProviderError("bad_endpoint", "mkdir endpoint could not be resolved.")) return if (result.isSuccessful) ApiResponse.Success(Unit) else ApiResponse.Error(statusError(result))Read an answer with
parseJsonandnodeFrom(endpoint, item), which applies the endpoint'sresponse_map. 3. Turn on what it unlocks. When the endpoint makes the host able to do something new, add it to thecapabilitiesbuildSetat the top of the class (if (config.endpoints.containsKey(ENDPOINT_FOO)) add(ProviderCapability.…)). 4. If it's a new kind of action, provider-api needs it too: a value inProviderCapability(provider-api/…/Capabilities.kt), and a function in the right interface ofOps.kt. The other providers then implement it or leave it out. See Hosts in code. The interface reads capabilities, never guesses from which operations exist. 5. Offer it in the editor: add anEndpointInfo(name, area, help, placeholders)toKnownEndpointsinsettings/ui/configeditor/ConfigSchema.kt, next to the endpoints of the same area. Theareais the heading it's grouped under ("Files tab","Upload tab","Filesystem tab","Lists tab","Account","Previews and links"); a name the editor doesn't know shows under Other, and Add an endpoint only offers known ones. A newresponse_mapkey the provider reads goes intoresponseFieldSuggestionsin the same file. 6. Use it in the bundled Pixeldrain config, if Pixeldrain has it:docs/provider-configs/pixeldrain.json(bundled as an asset). Raise itsmeta.version, sorefreshBundledPixeldrainConfigupdates the copies already installed, and add the name to the set inExampleConfigsTest(provider-api/src/test/…).ConfigSchemaTestchecks that every endpoint of that config is inKnownEndpoints. 7. Document it in the website'sdocs/hosts/rest-api.md, in the table of its tab, with its placeholders.
Nothing changes in ConfigUpdates: an endpoint's full https:// address already counts as a destination when an
update is checked.
Adding a field to a config kind¶
A field of a WebDAV, S3, SMB or REST config is a property of its data class. The template and the codec follow from the class; the editor and the documentation are written by hand.
-
Add the property to the data class in
provider-api/…/ProviderConfig.kt(WebDavConfig,S3Config,SmbConfigorGenericRestConfig), with a@SerialNamein snake case and a default:/** Encrypts the traffic on the wire. Needs SMB 3 on the host, the connection fails when it doesn't offer it. */ val encrypt: Boolean = false,The default is what keeps every existing config readable: a field left out means the default. A field without one is required, and every config written before it would stop loading. 2. The template (
ProviderConfigTemplates) is made from the class with every field written out, so the field shows up there by itself. Set an example value inProviderConfigTemplates.config(kind)only if the default doesn't show what the field is for. 3. Describe it for the editor: add aFieldSpecto the kind's sections insettings/ui/configeditor/ConfigSchema.kt(webDavSections,s3Sections,smbSections,genericRestSections). Give the JSON name askey, a shortlabel, a one-linehelp, thekind(Text,Number,Toggle,Choice,Group), and the samedefaultas the data class, as aJsonPrimitive. Setcommon = trueonly for fields most configs need; the rest are under More options.ConfigSchemaTestfails until every field of every template has a place in the form. 4. Use it in the kind's provider:provider-webdav,provider-s3,provider-smb, orGenericRestStorageProvider.ProviderRegistry(provider/ProviderRegistry.kt) passes the config to it. 5. If it changes where or how the sign-in is sent (an address, a port, an auth mode), add it toConfigUpdates.originsOforauthOfinprovider-api/…/ConfigUpdates.kt. Then an update that changes it is aSensitiveChangeand needs the user's approval. CheckHostHealth(hosts/HostHealth.kt, what it checks) andHostSwitcher(the address it shows) too, as both read each kind's address. 6. Document it in the website's page of the kind (docs/hosts/webdav.md,s3.md,smb.mdorrest-api.md), and use it in an example underdocs/provider-configs/if it helps.ExampleConfigsTestdecodes every example.
Adding a file preview type¶
The file details choose a preview from the file's type. A new kind of preview is a composable in ui/media/ and a
branch in FileInfoDetailsCard. See Thumbnails and previews for how the existing ones load.
- Recognize the file.
StorageNode.previewMimeType()(files/FileInfoViewModel.kt) gives the host's type, or one worked out from the extension when the host's is blank orapplication/octet-stream. Match on its prefix, as the image, video and audio previews do, or on the extension, as archives do (isArchiveName,ARCHIVE_EXTENSIONS). - Write the preview in a new file in
ui/media/. Take the file's address fromfileInfoViewModel.rawUrlFor(file)and theapiKeythe details pass (only filesystem files need the sign-in for a preview), and takeouterPaddinglikeInlineImagePreviewdoes. ReadLocalReduceMotionwherever it moves andLocalBlurredBackdropif it shows a backdrop. - Load what it needs first, if anything (text, an archive's listing), in
FileInfoViewModel: a field inFileInfoUiState, and a load started where a file is opened, infetchFileInfo(by id) andsetFileInfoFromNode(from a listing), next tofetchTextFilePreviewContentandloadArchive. Clear it when another file opens, or the last file's preview stays on screen. - Show it: add a branch to the
if … else ifchain underif (showPreviews)inFileInfoDetailsCard(files/FileDetailsScreen.kt), before the thumbnail fallback at the end. If it shows the file's own picture, add it tomediaPreviewFirstandpreviewShowsPicture, so the header doesn't repeat the picture with a tile. - Full screen, if it has one:
FullScreenMediaPreviewDialog(ui/media/FullScreenPreview.kt) handles video and images; anything else shows Unsupported preview type for fullscreen. until it gets a branch. - Give it a tile, if it's a new kind of file: a
FileKindand its extensions inui/components/FileListItem.kt, so the lists show its icon and colour. A text format that should be coloured goes intoSourceLanguages.byExtensioninfiles/SourceHighlighting.kt, which also makesisTextFileshow it as text. - Document it under File details in the website's
docs/user-guide/screens.md.
Adding an icon¶
The app keeps only the Material icons it uses, not material-icons-extended (thousands of icons, about 30 MB of the
debug APK). They're in ui/icons/, one line each, named as in the library, so the code reads the same:
| File | Icons |
|---|---|
filled/ |
Icons. |
outlined/ |
Icons. |
automirrored/ |
Icons.: icons that flip in right-to-left languages (arrows, "open in new") |
App |
icon(), which builds an icon from its path the way the library does |
- Find the icon at fonts.google.com/icons, in the Material Icons set (not Material Symbols, which are drawn differently), in its style (Filled or Outlined).
- Download its SVG (24 px) and copy the
dof its<path>. -
Add a line for it to the file of its style, sorted by name. One with several paths passes each:
icon("Filled.Name", "M…", "M…"). One that points somewhere goes inAutoMirroredFilledIcons.kt, withautoMirror = true.val Icons.Filled.Add: ImageVector by lazy { icon("Filled.Add", "M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2Z") } -
Import it from the app (
Iconsitself still comes frommaterial-icons-core):import androidx.compose.material.icons.Icons import tools.senko.materialdrain.ui.icons.filled.Add
For many icons at once, add material-icons-extended as a testImplementation for a moment and write the lines from
the library's own ImageVectors in a unit test (their root holds the paths as PathNodes), the way the files were
first made; then remove it again.
Changing the app's icon¶
There is one logo, res/drawable/pixeldrain_launcher.xml (white), and one splash copy of it,
res/drawable/pixeldrain_splash.xml (in @color/pixeldrain_icon: a launcher icon can't take its colour from the
theme, so the splash's coloured one is its own file). To change the logo, change those two.
The adaptive icon (mipmap-anydpi/ic_launcher.xml) places the logo through ic_launcher_foreground_padded.xml and, for
themed icons, ic_launcher_monochrome_padded.xml. Their padding is a share of the icon (android:inset="15%"), not
a size in dp. The icon is drawn at many sizes: large on the launcher, small in the status bar (OxygenOS and ColorOS show
the app's icon there) and smaller still in a file manager's list. A padding in dp is the same at every size, so the
small icons were nearly all padding: a sliver of the logo, or a dot. To make the logo bigger or smaller everywhere,
change the inset of both _padded files. Keep the logo inside the middle two thirds, which the launcher's mask shows.
The splash screen is always drawn at one size, so ic_splash_icon_padded.xml keeps its padding in dp.
Logging¶
Diagnostics go through ProviderLog (provider-api/…/ProviderLog.kt), which every module can reach.
-
Call
ProviderLog.d,i,worewith a category and a message:ProviderLog.i("Config", "imported the bundled '${source.config.name}' config and made it the active host") ProviderLog.e("Upload", "the upload failed: ${e.message}", e) -
Use an existing category where one fits:
Files,Lists,Filesystem,Http,Auth,Config(Uploadis used too). Every line is taggedMD/<category>. - Never log a credential. Log its length or kind ("api key, 40 chars"), not its value.
GenericRestStorageProviderlogs only the header's name and whether it's a session. - For a server's error, log the start of its answer with
ProviderLog.snippet(body): the first 300 characters on one line, enough to read the message without a whole page. - Read them with logcat, filtered on the
MD/tags, e.g.adb logcat -s MD/Http MD/Auth, or in Android Studio's Logcat withtag:MD/.
Some app code still logs with android.util.Log under its own tags (App, FilesystemViewModel). New diagnostics
should use ProviderLog, so they're found in one place.