Architecture¶
Materialdrain is one Android app built from seven Gradle modules. The app module holds the screens; six library modules hold the hosts. This page shows how they fit together, how the app starts, and how a tap on a screen turns into a request and back into something on the screen.
The modules¶
| Module | What it holds |
|---|---|
app |
The activity, every screen, the ViewModels, settings, transfers, the stored host configs and their updates |
provider- |
The contract every host implements (Storage and its ops), the shared models, the config classes and their JSON codec. No HTTP code |
provider- |
The built-in Pixeldrain host, and the Pixeldrain account login. Ktor for JSON calls, OkHttp for streaming |
provider- |
generic_ configs: any HTTP API described request by request. OkHttp |
provider- |
webdav configs: Nextcloud, ownCloud, TrueNAS and other WebDAV servers. OkHttp |
provider- |
s3 configs: any S3-compatible bucket, with its own SigV4 signer. OkHttp |
provider- |
smb configs: Windows, Samba and NAS shares, through jcifs-ng |
All seven are listed in settings.gradle.kts. Every host module is an Android library (minSdk 29), since the ops
take Android types such as Uri and Context.
flowchart TD
app["app"]
api["provider-api"]
pd["provider-pixeldrain"]
rest["provider-generic-rest"]
dav["provider-webdav"]
s3["provider-s3"]
smb["provider-smb"]
app --> api
app --> pd
app --> rest
app --> dav
app --> s3
app --> smb
pd --> api
rest --> api
dav --> api
s3 --> api
smb --> api
Each arrow is an implementation(project(...)) line in the module's build.gradle.kts. The host modules only know
provider-api, never each other.
Why the hosts are separate modules¶
The screens and ViewModels only ever talk to the types in provider-api. The comment on StorageProvider says it
plainly: "The UI never talks to Pixeldrain/WebDAV/S3/generic-REST types directly — it goes through this interface and
reads capabilities to know what it can show." Keeping each host in its own module makes that a rule the build
enforces rather than a habit:
- A host can't leak into another.
provider-webdavcan't reach intoprovider-s3, because it doesn't depend on it. What they share has to go throughprovider-api. - Each host brings only its own libraries. jcifs-ng is only in
provider-smb. The S3 module signs requests itself rather than pulling in the AWS SDK, "which would drag in a large dependency graph for three HMAC calls" (fromS3Signer). - Each host is tested on its own. The config-driven host modules (REST, WebDAV, S3, SMB) and
provider-apihave their own unit tests, run on the plain JVM (the WebDAV provider usesjava.net.URLEncoderinstead ofandroid.net.Urifor exactly that reason). provider-apican't see the app. For example the app'sScreenenum can't be referenced from there, soHostScreen.toScreen()inNavigation.ktis the one place the two are matched up.
The app module does depend on every host module, but it only names their classes in a few places:
| Where | Why |
|---|---|
provider/ |
Builds the right Storage for each kind of config |
App |
Creates the built-in Pixeldrain, and tells Smb where to cache thumbnails |
auth/, settings/ |
The Pixeldrain account login, which has no equivalent on other hosts |
Everything else, from the Files screen to the transfer notifications, works the same for every host.
Inside app¶
All packages are under tools.senko.materialdrain.
| Package | What it holds |
|---|---|
| (top level) | Main, App. (the Compose shell), Navigation. and Host (the tabs), App, App, App (links, shortcuts and shared text), Upload (the Quick Settings tile), App, View |
browser |
Browser, the one screen behind Files, Lists and Filesystem, its controls (the ⋮ menu, the status cards, search), the search results, and the bar for shared files and moves |
files |
The Files screen's ViewModel (File), the file details page, file menus, the selection bar, sorting, downloads (Download), archive contents and the device's own zip reading (Device), text highlighting, Markdown |
filesystem |
Filesystem: folder browsing, uploads into folders, the folder tree index for search. One per Filesystem tab of a host |
upload |
The Upload screen and Upload |
lists |
List: lists of files and their contents |
transfer |
Transfer (every running upload and download), the foreground Transfer, notifications, speed |
provider |
Provider, Provider, Provider, the bundled Pixeldrain config, error messages |
settings/ |
The settings screens and their catalog, the account and its login, the custom hosts (Advanced), the theme's colour picker |
settings/ |
The config editor and Config, which describes every field of every kind |
settings |
App: the app's own choices (sorting, the theme, the lists' looks, the players, last screen, ...), and the data saver |
auth |
Session (the Pixeldrain key), Keystore, the app lock |
hosts |
The host switcher in the top bar, and Host, which checks whether each host answers |
documents |
Host: the hosts in the system's file picker |
ui/ |
Shared widgets: menus, dialogs, file rows and tiles, snackbars, the transfer status bar, the text and Markdown previews, sideways swipes |
ui/ |
Previews and players: images, animated images, video, PDFs, songs (the background Playback, the miniplayer, the players, the queue, songs' tags), media covers |
ui/ |
Colours, type and the Materialdrain |
util |
Small helpers: date parsing, formatting, content URI info, the media cache |
How the app starts¶
sequenceDiagram
participant A as MainActivity
participant S as MaterialdrainScreen (App.kt)
participant C as AppContainer
participant F as ViewModelFactory
A->>A: splash screen, edge to edge, back handler
A->>S: setContent { MaterialdrainTheme { ... } }
S->>C: AppContainer.get(application)
C->>C: create stores, registry, providers (first call only)
S->>F: ViewModelFactory(application, container)
S->>F: viewModel(...) for each screen
S->>S: collect each ViewModel's uiState
MainActivity is a FragmentActivity (the app lock's biometric prompt needs one). It installs the splash
screen, adds the "press back again to exit" handler, takes files shared from other apps (ACTION_SEND,
ACTION_SEND_MULTIPLE) into AppContainer.pendingShares, and sets the content: MaterialdrainScreen() with the
AppLockOverlay on top. In onStart and onStop it tells the transfer registry and the app lock whether the app is
in front.
App.kt holds MaterialdrainScreen, the Compose shell: the top bar, the bottom navigation, the floating button,
the snackbars, the transfer bar, and whichever screen is current. It gets the ViewModels, collects their state, and
decides which tabs the active host has (see Screens follow the host).
Navigation.kt has the Screen enum (Upload, Files, Filesystem, File Details, Lists, Settings), the bottom bar,
and HostScreen.toScreen(). There is no navigation library: the current screen is a rememberSaveable value in
MaterialdrainScreen, and navigateTo just changes it (remembering the screen before, for going back). The last
screen is saved in AppSettings, so the app opens where it was closed.
AppContainer¶
AppContainer is a manual dependency container. Its doc comment gives the reason it exists: it is "shared by the
whole process so that the ViewModels and the UI always see the same HTTP clients and the same SessionManager." It is
a process-wide singleton, made on the first AppContainer.get(application).
| It creates | What for |
|---|---|
session |
The Pixeldrain API key, from a login or entered by hand |
pixeldrain |
The built-in Pixeldrain host. "This is the one place its HTTP client is created." |
user |
Pixeldrain's account API, for the login |
transfer, app |
Running transfers; the app's own settings |
provider |
The custom hosts, their sign-ins, and which host is active |
provider |
Turns the active host's id into a Storage |
ok |
Pixeldrain's OkHttp client, shared with the image loader and the media players so they reuse its connections |
provider |
Checks and applies updates of configs that have an update_ |
app, pending, host |
The app lock; files shared from other apps; whether each host answers |
transfer |
A View tied to the process, for the ViewModels that run transfers |
Its init block imports the bundled Pixeldrain config on the first start (and refreshes it when the app ships a newer
version), sets up Coil's image loader on the shared client, gives SmbContentServer a cache folder, installs
HostRequestAuth.headersFor (so images and media get the active host's login headers), and starts
providerUpdater.runAutoUpdates() in a process-wide coroutine scope.
Nothing in the app uses a dependency injection library. Every object is created once, in this one class, in the
order it's needed; something that needs an object gets it from the container, or as a constructor argument from
ViewModelFactory. A few things that live outside Compose reach the container directly with AppContainer.get:
TransferService, and the media players' data source (for the shared OkHttp client).
ViewModelFactory¶
ViewModelFactory(application, container) builds each ViewModel by hand, passing in what it needs from the container:
| ViewModel | Gets |
|---|---|
Upload, File, Filesystem |
application, provider, provider, session, transfer, app |
List |
provider, provider, session, app |
Auth |
user, session |
Provider |
provider, provider, provider |
The three ViewModels that run uploads and downloads (UploadViewModel, FileInfoViewModel, FilesystemViewModel)
are created in transferViewModelStoreOwner rather than the activity. Being tied to the process, they and their
transfers survive the activity being closed while a transfer runs. See Uploads and downloads.
There is a FilesystemViewModel for each Filesystem tab, keyed by the host, the tab's id and its root
(filesystemViewModelFor in App.kt), so each tab keeps its own folder, listing and upload. bind gives it its host,
tab and root once, after it's made. Only the tab on screen loads anything (show and hide, from the page's
DisposableEffect): a change while a tab is hidden (a sign-in, a config edit, files deleted elsewhere) is caught up when
it's shown again. The folder open in a tab is kept by the host's id for its usual tab, as before there were several,
and by the host's and the tab's for the others. The other screens use the host's first Filesystem tab, e.g. for the
folder picker of Add to filesystem.
Hosts at run time¶
ProviderConfigStore¶
The store keeps every custom host as a StoredProvider: an id (a random UUID), the parsed ProviderConfig, and the
config's text exactly as it was written. The text is what's saved, exported and edited, so fields the app doesn't
read are kept (see The config format). Next to it are the
update state: the last version from the update URL, the version before the last update (for Revert update),
auto-update, a pending update, the ETag and the time of the last check.
- Configs are saved in the
provider_prefsshared preferences, as JSON, never with a secret in them. - Sign-ins (API key, username, password, session token) are each encrypted with
KeystoreCipherin their own entry ofprovider_secure_prefs. See Data and security. providers: StateFlow<List<StoredProvider>>andactiveProviderId: StateFlow<String>are what the rest of the app reads.changes: StateFlow<Int>counts every change of the hosts, the active host or their sign-ins. More on it below.
ProviderRegistry¶
ProviderRegistry.resolve(id) returns the StorageProvider for a host id:
- The built-in id,
pixeldrain-default, always gives the built-inPixeldrainStorageProvider. It is never stored inProviderConfigStore. - Any other id is looked up in the store, and
build(stored)makes a provider for its kind of config. - An id that isn't there (the host was removed) falls back to the built-in Pixeldrain.
fun build(stored: StoredProvider): StorageProvider {
return when (val config = stored.config) {
is GenericRestConfig -> GenericRestStorageProvider(
id = stored.id,
config = config,
credentials = { ownOrAccountCredentials(stored.id, config) },
onLoginToken = { configStore.saveLoginToken(stored.id, it) }
)
is WebDavConfig -> WebDavStorageProvider(stored.id, config, credentials = { configStore.credentials(stored.id) })
is S3Config -> S3StorageProvider(stored.id, config, credentials = { configStore.credentials(stored.id) })
is SmbConfig -> SmbStorageProvider(stored.id, config, credentials = { configStore.credentials(stored.id) })
}
}
Credentials are passed as a function, not a value: each provider reads them on every request, so a new key applies at
once. A generic_rest host with account_fallback and no sign-in of its own gets the Pixeldrain account's key
instead, but only when its base_url is pixeldrain.com (allowsAccountFallback).
Built providers are kept in a map and reused, since the screens ask for one per row and building one creates an HTTP
client. The map is cleared whenever configStore.changes has moved on since it was filled.
Two kinds of Pixeldrain¶
There are two ways the app talks to Pixeldrain, and it's worth knowing which is which:
- The built-in host (
PixeldrainStorageProvider, idpixeldrain-default), written in Kotlin, signed in from Settings → Account. It isn't a config, soscreensdoesn't apply: its tabs follow its capabilities. - The bundled
pixeldrain.jsonconfig, ageneric_restconfig like any other.BundledProviderConfigs.ktimports it on the first start and makes it the active host. It is only offered once: a user who removes it keeps it removed. Since it has noupdate_url,refreshBundledPixeldrainConfigbrings an imported copy up to the version the app ships, as an update (so Revert update still works). The file comes fromdocs/provider-configs/of the app's repository, whichapp/build.gradle.ktsadds as an assets folder.
ProviderUpdater¶
ProviderUpdater fetches a config's update_url (https only, at most 256 KB, with If-None-Match), hands the text
to ConfigUpdates.evaluate in provider-api, and records the outcome in the store. runAutoUpdates() runs once at
start, for configs with auto-update on and not checked in the last 24 hours, and applies only updates with no
sensitive changes. Applying an update that sends requests to a new address or a new kind of host clears the saved
sign-in. The rules are on Sharing and updates; the code is in
Hosts in code.
How data flows¶
Each ViewModel keeps its state in a private MutableStateFlow and exposes it as a read-only StateFlow:
private val _uiState = MutableStateFlow(UploadUiState())
val uiState: StateFlow<UploadUiState> = _uiState.asStateFlow()
MaterialdrainScreen collects them with collectAsState() and passes the values down. Values that only drive effects
(an error to show in a snackbar, say) are read through derivedStateOf, so that a transfer's progress tick doesn't
recompose the whole shell.
A ViewModel never holds on to a provider. It asks the registry for the active one each time:
private fun activeProvider(): StorageProvider = registry.resolve(configStore.activeProviderId.value)
A typical request¶
Opening a folder in the Filesystem screen:
sequenceDiagram
participant UI as BrowserScreen
participant VM as FilesystemViewModel
participant R as ProviderRegistry
participant P as StorageProvider
UI->>VM: fetchPathContent(path)
VM->>VM: uiState: isLoading = true
VM->>R: resolve(activeProviderId)
R-->>VM: provider (built or reused)
VM->>P: browse.list(path) in viewModelScope
alt ApiResponse.Success
P-->>VM: StorageListing
VM->>VM: uiState: children, path, canWrite, canDelete
else ApiResponse.Error
P-->>VM: ProviderError
VM->>VM: uiState: errorMessage = error.forDisplay()
end
VM-->>UI: uiState collected, screen recomposes
The ViewModel never needs a try around the call: a provider reports every failure as ApiResponse.Error, and only a
cancellation is thrown (see Hosts in code). ProviderError.forDisplay() turns a refused
login into a message that says what to do about it.
Screens follow the host¶
ProviderConfigStore.changes is a counter that goes up on every change: a host added, edited, updated or removed,
another host made active, a key saved, a sign-in or sign-out. Several things listen to it:
- The ViewModels reload.
FilesystemViewModel,FileInfoViewModelandListViewModeleach collectconfigStore.changes.drop(1)(the first value is the current one, already loaded ininit) and start over on the new active host: the Filesystem screen goes to the new host's root, the Lists screen forgets the opened list. - The registry rebuilds.
resolvedrops its built providers when the counter has moved, so the next request gets a provider made from the new config and sign-in. - The tabs change.
MaterialdrainScreenrecomputes the active host's capabilities and screens withremember(configChanges). For a config host, the tabs areresolveScreens(config.screens, capabilities). For the built-in Pixeldrain they come from the capabilities: Upload always, then Files withENUMERATE, Lists withLISTS, Filesystem withBROWSE. A current screen the new host doesn't have falls back to its first tab. - Host health is checked again, so the host switcher knows which hosts answer.
That is how a new API key, a sign-in or a switch of host shows its files without a manual refresh.