Restores the "store projects in public storage" feature, gated to F-Droid builds (the required MANAGE_EXTERNAL_STORAGE permission is disallowed on Google Play). - Expose the build channel at runtime via BuildConfig.FDROID in the common module. - Declare the storage permissions only in src/fdroid/AndroidManifest.xml, swapped in for F-Droid builds. - Restore the storage-location toggle + file-access UI, gated on BuildConfig.FDROID; reconcile the toggle with the real location on open. - Build the GitHub release APK as the F-Droid flavor. - Extract the directory move into a tested FileSystem.moveDirectory() helper (fixes the same-path data-loss crash; runs off the UI thread). - Read the fdroid flag consistently across settings.gradle.kts and module scripts. - Document the F-Droid build flag in DEVELOPMENT.md.
7.8 KiB
Development
Running The App
There are several run configurations provided for IntelliJ, stored in /.run.
Desktop App
gradlew :desktop:run --args='--dev' This will run in development mode. To run in developmeny mode manually, simply
pass --dev as an
argument when running it. Passing nothing will run in release mode.
dev mode will use a separate config directory so that you don't accidentally mess with production data.
Android App
Select the Android run target in the IDE and run it.
You can install the development version alongside a production version, they will have different names and icons so you can tell them apart.
Running the Server
gradlew server:run
Running Tests
Our mocking library mockk does not yet support Kotlin/Native, thus we need to choose one of the JVM targets to write the tests for. We chose desktop:
gradlew desktopTest
And for the Server:
gradlew server:test
Checking code coverage
gradlew koverHtmlReport
The results of which will be here: Code Coverage Report
Writing Tests
Common Module Tests:
Most tests live in the desktopTest source set, but a few do live in commonTest
Testing utilities:
BaseTest sets you up for injecting with Koin and dealing with coroutines for testing.
TestProjectUtils.kt has functions for generating test data.
ComposeUI Module Tests:
Again, most tests live in the desktopTest source set, but a few live in commonTest
Useful reference for UI testing: Compose Test Cheatsheet
Overal Project Structure (modules)
flowchart TD
Base(["Base"]) --> Server["Server"] & Common(["Common"])
Common --> ComposeUI(["ComposeUI"]) & iOS["iOS"]
ComposeUI --> Android["Android"] & Desktop["Desktop"]
Server:::serverColor
iOS:::iosColor
Android:::androidColor
Desktop:::desktopColor
classDef serverColor fill:#f94144,stroke:#333,stroke-width:2px,color:#FFFFFF;
classDef iosColor fill:#f8961e,stroke:#333,stroke-width:2px,color:#FFFFFF;
classDef androidColor fill:#90be6d,stroke:#333,stroke-width:2px,color:#FFFFFF;
classDef desktopColor fill:#577590,stroke:#333,stroke-width:2px,color:#FFFFFF;
Build Variants
The F-Droid build flag
F-Droid builds are produced by the same modules as the Google Play build, but with a single build flag toggled. There are no Gradle product flavors; instead the flag is read directly from a Gradle property (or an environment variable) wherever it's needed:
- Property:
-Pfdroid=true(any non-empty value works) - Environment variable:
FDROID_BUILD(any value, even empty, enables it)
Build the F-Droid APK locally with:
./gradlew :android:assembleRelease -Pfdroid=true
Omitting the flag produces the default (Google Play) build.
What the flag changes
| Location | Effect when set |
|---|---|
settings.gradle.kts |
Skips the foojay toolchain resolver (F-Droid can't reach foojay) and excludes the :desktop module from the build. |
common/build.gradle.kts |
Emits BuildConfig.FDROID = true (via the buildConfig {} block) so runtime code can branch on the build channel. Reachable from common, composeUi, and android. |
android/build.gradle.kts |
Swaps the app manifest to android/src/fdroid/AndroidManifest.xml, which additionally declares the storage permissions needed for public-storage projects. |
Public-storage projects (F-Droid only)
Storing projects in shared/public storage requires MANAGE_EXTERNAL_STORAGE (All Files
Access), which Google Play does not allow, so the feature is gated to F-Droid builds:
- The permissions live only in
android/src/fdroid/AndroidManifest.xml. That file is a full copy ofsrc/main/AndroidManifest.xmlplus the storage permissions — if you add or remove an activity/receiver/provider in the main manifest, mirror the change there. - The settings UI (
PlatformSettingsUi.android.kt) shows the storage-location toggle only whenBuildConfig.FDROIDis true. HammerApplicationforces internal storage on non-F-Droid builds, so a leftover preference can never point a Google Play build at a directory it has no permission for.
When adding new runtime behaviour that should differ between channels, branch on
com.darkrockstudios.apps.hammer.common.BuildConfig.FDROID rather than re-reading the
Gradle property.
Client Development
Client Architecture
Please check out the Architecture doc for a deeper dive into the Client Architecture
Coroutines
Repository Layer
Repositories will need to declare their own coroutine scope, there is no common base class to do so.
// The various dispatcher can be injected as such
private val mainDispatcher by injectMainDispatcher()
private val defaultDispatcher by injectDefaultDispatcher()
private val ioDispatcher by injectIoDispatcher()
Component layer
Component base class ComponentBase has a coroutine scope defined already: scope
This scope will be canceled for you when the component is destroyed.
You can inject the various contexts as such:
private val mainDispatcher by injectMainDispatcher()
private val defaultDispatcher by injectDefaultDispatcher()
private val ioDispatcher by injectIoDispatcher()
// `scope` here is from the `ComponentBase` parent class
scope.launch {
// Scope uses the default dispatcher, so make sure to switch contexts when necessary
withContext(mainDispatcher) {
// Make sure you update all of your state variables on the main thread
}
}
UI Layer: Compose
// Define your own, or use scope hoisting to a parent Composable
val scope = rememberCoroutineScope()
// inject which ever dispatcher you need
val mainDispatcher = rememberMainDispatcher()
val defaultDispatcher = rememberDefaultDispatcher()
val ioDispatcher = rememberIoDispatcher()
scope.launch(defaultDispatcher) {
// Do stuff in background
withContext(mainDispatcher) {
// Back on main thread
}
}
Logging
Client
On the client you can log using Napier it works on all supported platforms:
Napier.i("message")
Napier.w("message")
Napier.e("message")
Napier.d("message")
Server
On server you can log anywhere you have access to the ktor Application
log.info("message")
log.debug("message")
You can also access it from a ktor Call object:
call.application.environment.log.info("Hello from a Call!")
If you need logging below the HTTP layer 🤷 Pass the logger down? Idk we don't have a great solution for this yet.
Synchronization
The protocol for synchronizing data between client and server is outlined here: SYNCING-PROTOCOL.md
How to Release
When develop is ready to release, run: ./gradlew prepareForRelease
For full instructions check out the full doc here.
Re-generate open source library data
This data drives the Opensource Licenses UI in the apps.
Desktop Target:
Must be regenerated manually when an open source dependency is added/changed:
./gradlew :desktop:exportLibraryDefinitions -P"aboutLibraries.exportPath=src\jvmMain\resources"
Android Target:
Auto-generated on every build by the aboutlibraries.plugin.android plugin into
android/build/generated/aboutLibraries/<variant>/res/raw/aboutlibraries.json. No manual step required.
iOS Target: ???
Asset Generation
All graphical assets (app icons, store-listing graphics, MSIX tiles, favicons,
the Play Store feature graphic, the Snap featured banner, etc.) are generated
from a single manifest at scripts/assets.yaml. Run scripts/generate-assets.sh
to (re)build everything.
See ASSET-GENERATION.md for the manifest schema, dependencies, asset types, and how to add or modify outputs.