Development¶
Materialdrain is a Kotlin app with a Jetpack Compose interface, split into an app module and one module per kind of host. This page gets you from a fresh clone to a running build, and explains the branches, the build workflow and how versions are made. The other pages in this section go into how the code works.
What you need¶
| Tool | Version | Where it's set |
|---|---|---|
| JDK | 17 (the build workflow uses Temurin 17) | . |
| Gradle | 9.8.0, through the wrapper: nothing to install | gradle/ |
| Android Gradle Plugin | 9.4.1 | gradle/ |
| Android SDK | Platform 37 (compile) |
app/ and each module's build. |
The app runs on Android 10 and newer (minSdk = 29) and targets API 36 (targetSdk = 36). The code is compiled for
Java 11 (sourceCompatibility, jvmTarget), which a JDK 17 builds without anything extra.
Android Studio finds the SDK on its own. From the command line, point Gradle at it with ANDROID_HOME or a
local.properties file containing sdk.dir=…; local.properties is in .gitignore, so it stays on your machine.
Getting the code¶
git clone https://github.com/fidesosu/Materialdrain.git
cd Materialdrain
git switch dev
The branches¶
| Branch | What it holds |
|---|---|
main |
Stable releases. Every push to it publishes a release |
dev |
Development. Work happens here; every push publishes the dev- pre-release |
website |
This documentation and the home page. It shares no history with the app's branches |
site |
The built website, made from website by its workflow. Never edited by hand: each build replaces it |
Changes to the app go to dev first, and reach main when they're ready to be a release. The documentation lives on
website so that editing it never starts an app build, and the app's branches hold only the app; see
This website.
Building¶
./gradlew assembleDebug
The APK lands in app/build/outputs/apk/debug/app-debug.apk; ./gradlew installDebug puts it on a connected phone
or emulator. A debug build has the same applicationId as the releases, tools.senko.materialdrain, but is signed with
your machine's debug key, so Android won't install it over a release build: uninstall that first.
./gradlew assembleRelease needs the release key (see Signing), so locally you'll usually want the debug
build. To try a change on a phone that has a release installed, build a release instead, as a debug build can't be
installed over it.
| Debug | Release | |
|---|---|---|
| Shrunk by R8 | no | yes (is, is), with app/ |
| Size | about 64 MB | about 9 MB |
| Time | about 20 s after a small change | about 2 min, 75 s of it R8 going through every library |
R8 and the SMB provider¶
The SMB provider uses jcifs-ng, which brings in BouncyCastle. Most of BouncyCastle is reached directly by code, so R8
keeps what's used and drops the rest. What's found by name at runtime has to be kept by a rule in
proguard-rules.pro:
- all of
jcifs.**, which finds its own protocol classes by name; - from BouncyCastle, only its JCA provider (
BouncyCastleProvider) and the two algorithms jcifs-ng asks it for by name:MD4(the NTLM sign-in) andAESCMAC(SMB 3 signing). The provider finds an algorithm's classes by name too, and skips the ones that aren't there.
Keeping all of BouncyCastle made it about 40% of the app's code (13 MB instead of 9). If an SMB sign-in fails on a
release build with a missing algorithm, jcifs-ng asked for one more by name: keep its classes too, e.g.
-keep class org.bouncycastle.jcajce.provider.digest.SHA256* { *; }. -keep class org.bouncycastle.** { *; } always
works, at the old size. BouncyCastle's data files for what isn't used (post-quantum Picnic, certificate path messages)
are left out in packaging.resources.excludes in app/build.gradle.kts.
Testing¶
./gradlew test
The tests are plain JUnit 4 unit tests that run on your computer, with no phone or emulator.
./gradlew :provider-s3:test runs one module's.
| Module | What its tests cover |
|---|---|
app |
Sorting files, date parsing, text and code highlighting, the config editor's schema, the rolling-number animation's plan |
provider- |
Reading and writing configs, why an import is refused, config updates, screens and templates, the bundled example configs, where the account login may go, zip listings |
provider- |
Auth headers, dot-paths, the login flow, listing, and the bundled Pixeldrain config against a fake server |
provider- |
Auth, media URLs, the XML answers, and the provider against a fake server |
provider- |
Request signing, the XML answers, and the provider against a fake server |
provider- |
Paths |
provider- |
No tests yet |
The HTTP hosts are tested against OkHttp's MockWebServer, so a test sends real requests to a server it controls.
More on how tests are written in Conventions.
The modules¶
flowchart TD
app --> api[provider-api]
app --> pd[provider-pixeldrain] --> api
app --> rest[provider-generic-rest] --> api
app --> dav[provider-webdav] --> api
app --> s3[provider-s3] --> api
app --> smb[provider-smb] --> api
| Module | In one line |
|---|---|
app |
The Android app: screens, ViewModels, settings, transfers, previews, and the store of hosts |
provider- |
What every host is to the app: the Storage interface, its results, and the config format |
provider- |
The built-in Pixeldrain host, the one you sign into under Account |
provider- |
Any REST API described by a config, including the bundled Pixeldrain config |
provider- |
WebDAV servers, such as Nextcloud |
provider- |
S3 buckets, with its own request signing |
provider- |
SMB shares, through jcifs-ng |
The provider modules only know provider-api, never each other or the app. How they fit together is in
Architecture.
The build workflow¶
.github/workflows/build.yml tests and publishes the app. It runs on pushes and pull requests to main and dev,
but only when something that goes into the APK changed: app/, the provider-* modules, the example configs in
docs/provider-configs/ (they're bundled as assets), the Gradle files and the wrapper. Markdown files never count.
Run workflow on the Actions tab runs it anyway.
flowchart LR
change[Push or pull request] --> test[./gradlew test]
test -->|pull request| debug[Debug APK, kept on the run's page]
test -->|push to main| stable[Release v<version>, marked latest]
test -->|push to dev| dev[Pre-release dev-build, replaced each time]
| Event | What happens after the tests pass |
|---|---|
| Pull request | A debug APK is built and attached to the run. It never sees the signing key |
Push to main |
A signed release build is published as its own GitHub release, tagged v<version> and marked the latest. Its notes list the changes since the release before. Older releases stay |
Push to dev |
The same build with - added to its version, published as the one pre-release tagged dev-. The tag moves to the new commit, and the APK of the build before is removed |
The APK is named Materialdrain-<version>.apk. The home page lists these releases.
A newer push to the same branch cancels a build that's still running.
Signing¶
Release builds are signed with the release key, which lives in the repository's Actions secrets:
| Secret | What it is |
|---|---|
KEYSTORE_ |
The keystore file, base64-encoded. The workflow decodes it to materialdrain- at the root |
KEYSTORE_ |
The keystore's password |
KEY_ |
The password of the key, whose alias is materialdrain |
app/build.gradle.kts reads the file and the two passwords (from environment variables of the same names). The
workflow fails with a clear message when KEYSTORE_BASE64 isn't set, and deletes the key file at the end, even when
the build failed. *.jks files are in .gitignore, so switching or resetting branches never touches a local copy.
To build a release locally, put the key at the root of the project as materialdrain-release.jks and set the two
passwords as environment variables. Keep a copy of the key and its passwords somewhere outside the project: the
secrets can't be read back from GitHub, and without this key no update installs over the released app.
Versions¶
Nobody sets the version by hand. app/build.gradle.kts makes it from the time of the commit being built:
| Made of | Example | |
|---|---|---|
version |
1., then the commit's date and time in UTC as yyyy, then - for builds of dev |
1. |
version |
The minutes from the start of 2025 (UTC) to the commit | 938398 |
- Every newer commit gets a higher version code, so a newer build always installs over an older one. That goes
for stable and dev builds alike: they share the
applicationId, so you can switch between them. - The commit's time, not the build's. Building the same commit again gives the same version, so a rebuild on CI replaces the APK of its release instead of making a new one.
1.5isbaseVersion. Change it only for a big step.- The
-devsuffix comes from theVERSION_SUFFIXenvironment variable, which the workflow sets ondev. - Without git (a source download, say), the current time is used instead.
The version code is also what a config's min_app_version compares against; see
Sharing and updates.
Where to go next¶
- Architecture: how the modules, the app's container and the screens fit together.
- Hosts in code:
StorageProvider, capabilities, and how each kind of host is built. - The interface: the screens, navigation and the Compose components they share.
- Uploads and downloads: transfers, progress, cancelling, and the background service.
- Thumbnails and previews: images, video, audio, text and archives.
- Search and sorting: searching folders and ordering lists.
- Data and security: what's stored where, and how sign-ins are encrypted.
- Common tasks: recipes for the changes people make most.
- Conventions: how the code is written, and why.
- This website: how these pages and the home page are built and published.