Skip to content

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) .github/workflows/build.yml
Gradle 9.8.0, through the wrapper: nothing to install gradle/wrapper/gradle-wrapper.properties
Android Gradle Plugin 9.4.1 gradle/libs.versions.toml
Android SDK Platform 37 (compileSdk) app/build.gradle.kts and each module's build.gradle.kts

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-build 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 (isMinifyEnabled, isShrinkResources), with app/proguard-rules.pro
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) and AESCMAC (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-api 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-generic-rest Auth headers, dot-paths, the login flow, listing, and the bundled Pixeldrain config against a fake server
provider-webdav Auth, media URLs, the XML answers, and the provider against a fake server
provider-s3 Request signing, the XML answers, and the provider against a fake server
provider-smb Paths
provider-pixeldrain 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-api What every host is to the app: the StorageProvider interface, its results, and the config format
provider-pixeldrain The built-in Pixeldrain host, the one you sign into under Account
provider-generic-rest Any REST API described by a config, including the bundled Pixeldrain config
provider-webdav WebDAV servers, such as Nextcloud
provider-s3 S3 buckets, with its own request signing
provider-smb 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 -dev added to its version, published as the one pre-release tagged dev-build. 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_BASE64 The keystore file, base64-encoded. The workflow decodes it to materialdrain-release.jks at the root
KEYSTORE_PASSWORD The keystore's password
KEY_PASSWORD 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
versionName 1.5., then the commit's date and time in UTC as yyyyMMdd.HHmm, then -dev for builds of dev 1.5.20261014.1558
versionCode 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.5 is baseVersion. Change it only for a big step.
  • The -dev suffix comes from the VERSION_SUFFIX environment variable, which the workflow sets on dev.
  • 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