Thank you for your interest in contributing to Flow! We welcome contributions from the community.
Flow (io.github.aedev.flow) is an Android music/video app written in Kotlin with Jetpack Compose,
Hilt, and Media3/ExoPlayer. It plays YouTube content via a native InnerTube client with a
NewPipe-based fallback extraction path, and supports local media playback, offline downloads,
casting, lyrics, device-to-device sync, and an on-device recommendation engine (FlowNeuroEngine).
Read this file before opening a pull request. Most review round-trips on this project come from
one of the rules below, not from the feature itself. AGENTS.md in the repository root carries the
same rules in the form AI coding agents consume — the two files are kept in sync, so if you use an
AI assistant, point it at AGENTS.md.
Use GitHub Discussions — that is the right place, and questions are welcome. The codebase is deliberately light on comments (see "Comments" below), so asking is expected rather than a sign you missed something.
Please ask before writing a large change, especially anything that touches architecture, the player path, or more than a handful of files. A design question answered in a discussion thread costs everyone far less than a rewritten pull request.
Before creating bug reports, please check existing issues to avoid duplicates. When creating a bug report, include:
- Clear description of the issue
- Steps to reproduce the behavior
- Expected behavior vs actual behavior
- Screenshots if applicable
- Device information (Android version, device model)
- App version or commit hash
Feature suggestions are welcome! Please:
- Check if the feature has already been suggested
- Provide a clear description of the feature
- Explain why this feature would be useful
- Include mockups or examples if possible
- Fork the repository
- Create a new branch from
main - Make sure you can build the project
# Clone your fork
git clone https://github.com/YOUR_USERNAME/Flow.git
cd Flow
# Add upstream remote
git remote add upstream https://github.com/A-EDev/Flow.git
# Create a feature branch
git checkout -b feature/your-feature-nameAlways pull the latest main before starting work, to minimize merge conflicts.
Flow builds two flavors: github (default, in-app updater enabled) and foss (no updater).
Always use flavor-prefixed Gradle tasks — assembleGithubDebug, compileFossDebugKotlin —
never bare assembleDebug or compileDebugKotlin.
- All UI is built with Jetpack Compose Material 3 (
androidx.compose.material3). Never introduce Material 2 (androidx.compose.material.*) components into new or edited code. - Use
MaterialTheme.colorScheme,MaterialTheme.typography,MaterialTheme.shapes, andMaterialTheme.motionSchemetokens exclusively for color, type, shape, and motion. Never hardcode a color, font size, corner radius, or animation spec that a theme token already covers. - Before implementing or fixing any Compose/Material 3 component, consult the official documentation (developer.android.com Compose docs, Material 3 component guidelines, Media3 docs) to confirm the current recommended API. The app tracks alpha Compose/M3, so blog posts and memory are frequently out of date.
- Respect Material 3 motion, elevation, and state-layer specs as documented — do not invent custom equivalents when a Material 3 component already provides them correctly.
The following are strictly forbidden anywhere in this codebase. Pull requests containing them will be asked to remove them before review continues:
- ❌ No gradients — do not use
Brush.linearGradient/verticalGradient/etc. as a background or surface fill unless explicitly agreed, and never as a substitute for a real Material 3 surface color. - ❌ No glassmorphism — no blurred, frosted, or semi-transparent "milky" backgrounds
(
Modifier.bluron backgrounds, translucent overlay panels) outside the one existing, deliberately-designed ambient/blur surface in the player. Do not add new ones. - ❌ No fake drop shadows / glow effects — do not hand-roll
shadow()glows or neon box-shadow equivalents. Use Material 3's built-in elevation/tonal-elevation system only. - ❌ No colored borders around cards — card and container borders must be neutral
(
MaterialTheme.colorScheme.outline/outlineVariant), never a primary/accent-colored stroke used as decoration. - ❌ No arbitrary hex codes — never write
Color(0xFF...)inline. All colors come fromMaterialTheme.colorSchemeor the app's color scheme source. If a color you need does not exist yet, add it to the theme definition inui/theme/, do not inline it at the call site.
If you find yourself reaching for any of the above because "it looks nice", stop — it is not the app's design language and it will be reverted.
The default is always the library implementation. Hand-rolling is a fallback that must be earned by verification, never a first move. A hand-rolled equivalent of a shipped API is strictly worse: it misses accessibility, RTL, theming, state restoration, edge cases and security fixes the real one already handles, it will not track upstream behaviour changes, and it becomes code the project has to maintain forever.
This applies to Material 3 and M3 Expressive components, Compose foundation/animation/gesture APIs,
Media3/ExoPlayer, AndroidX (WorkManager, Paging, Room, DataStore, Lifecycle, Glance), the platform
SDK, and every third-party library in gradle/libs.versions.toml. It applies equally to protocols
and formats — do not write a parser, codec, cipher, signature scheme or transport by hand when a
maintained implementation is on the classpath.
Before writing any component, effect, animation, formatter, parser, scheduler, or player behaviour:
-
Check what the app already has. Read
ui/components/shared/andutils/. The commonest waste is re-implementing something this codebase already exports —FlowEmptyState,FlowErrorState,MediaRow,MediaThumbnail,MediaBadges,FlowSearchField,ShimmerLoading, andFastScrollbarall exist already. -
Check the version catalog.
gradle/libs.versions.tomlis the list of what is available. -
Check the actual artifact, not your memory. The app is on alpha Compose/M3; confirm against the bytecode before concluding an API is missing:
find ~/.gradle/caches/modules-2/files-2.1/androidx.compose.material3 -name '*.aar' unzip -p <path>/material3.aar classes.jar > /tmp/c.jar unzip -l /tmp/c.jar | grep -i carousel javap -classpath /tmp/c.jar androidx.compose.material3.MotionScheme
Several versions can sit in the cache at once — confirm which resolves with
./gradlew :app:dependencies. -
Check the official docs for the recommended pattern. An API existing is not the same as it being the right one.
-
Only if steps 1–4 come back empty may you hand-roll it — and you must say so explicitly in the PR description, naming what you checked and what you found missing.
| Need | Use | Do not hand-roll |
|---|---|---|
| Any UI component | androidx.compose.material3 (M3 Expressive) |
Custom buttons, chips, sheets, FABs, carousels, progress indicators, search fields |
| Motion | MaterialTheme.motionScheme specs |
Bespoke tween/spring constants at call sites |
| Shapes | MaterialTheme.shapes, androidx.graphics.shapes |
Hand-drawn paths for standard shapes |
| Haptics | HapticFeedbackType constants |
Vibrator calls with hardcoded durations |
| Pull to refresh | M3 PullToRefreshBox |
Custom drag-to-refresh |
| Reordering | sh.calvin.reorderable |
Custom drag-and-drop |
| Paging | Paging 3 (androidx.paging) |
New hand-rolled page cursors |
| Playback | Media3 — exoplayer, hls, dash, session, datasource-okhttp | Custom player wiring, manifest handling, media notifications |
| Background work | WorkManager | Custom threads, timers, alarms, wakelocks |
| Persistence | Room, DataStore Preferences | Hand-rolled file or SharedPreferences layers |
| JSON | kotlinx.serialization |
Gson for new code (legacy DTOs only; it carries an R8 reflection hazard) |
| HTTP | OkHttp / Ktor | Raw sockets or HttpURLConnection |
| Dates and times | java.time (desugaring enabled, minSdk 26) |
SimpleDateFormat, Calendar, manual millisecond arithmetic |
| Images | Coil | Custom loaders, caches, decoders |
| Widgets | Glance + glance-material3 |
Hand-built RemoteViews |
Only when the API is verifiably absent or verifiably unfit, and the reason is recorded in the PR.
The reference case is ui/components/shared/FastScrollbar.kt: Compose Foundation ships no scrollbar
API, so a custom one was justified. Note what it still does — it is built on the platform
(draggable, LazyListState.requestScrollToItem, MotionScheme specs, HapticFeedbackType), it
does not reimplement any of them.
A legitimate hand-rolled component must be built from framework primitives rather than around them,
take theme tokens for every colour/shape/type/motion value, live in ui/components/shared/ if more
than one feature uses it, and duplicate no behaviour the library already provides.
- Never fork, vendor, or copy-paste a library's source into the app to change one thing.
- Never add a new dependency that overlaps one already on the list. A new dependency needs a stated reason that an existing one cannot cover.
- Never hand-roll security-relevant code — crypto, signing, token handling, TLS.
- Never re-invent player wiring. Media3 owns playback, the media session, and the media notification.
- Never disable, wrap, or work around a library API because its behaviour surprised you until you have confirmed the behaviour against its documentation. The surprise is usually a misuse.
Flow is a media player that runs for hours at a time. Jank, dropped frames, playback stutter, device heat, and battery drain are critical bugs, not cosmetic issues. Every rule below is anchored in a real shipped regression that had to be found and fixed on-device.
- The invisible-animation rule. Several player surfaces deliberately stay composed while hidden
(the full player sheet is kept warm behind the mini player; the lyrics panel is retained after
first open; the mini bar stays composed under the expanded player). Anything animating inside a
hidden layer burns a full frame budget at 60–120 Hz for entire listening sessions — this exact
pattern caused a 30%-battery-in-90-minutes overheating regression. Every continuous animation
must be gated on its own layer's visibility and pause when the layer is hidden. Gate on anchor
state (e.g.
state.isExpanded) or aderivedStateOfover the fraction. Never remove the warm composition itself — it exists to make first-expand jank-free. - Audit list for continuous work. When adding or reviewing UI, search the affected tree for:
withFrameMillis/withFrameNanosloops,rememberInfiniteTransition,basicMarquee,animate*AsStatewhose target changes on a timer (a 1 Hz-retargeted tween is a continuous animation), wavy/squiggly/indeterminate indicators, and pollingLaunchedEffects with shortdelays. Each needs an explicit answer to: "what stops this when its layer is hidden, when playback is paused, and when the screen is off?" - Per-frame values are read in the layout/draw phase, never in composition. The player sheets
pass animated values as lambdas consumed inside
Modifier.layout/graphicsLayer/drawBehindso dragging never recomposes the tree. Preserve that contract. For booleans derived from a fraction, usederivedStateOfso recomposition happens only on the flip. - Expensive draw effects. Full-screen
blur/RenderEffectlayers must hold static content (invalidated only on track/state change, asPlayerBackgrounddoes). Never attach a blur or render effect to a node that invalidates per frame.
- Position cadence is a contract.
EnhancedMusicPlayerManageremitsplayerStateat 1 Hz (whole-second coarsening) and a precise 250 ms tick only while a refcounted consumer (acquirePreciseProgress/releasePreciseProgress, tied to the expanded sheet) is present. Never widen these, never add a new high-frequency positionStateFlow, and never collect the precise tick from a surface that renders whole seconds. New sub-second consumers must use the same refcounted acquire/release pattern with aDisposableEffect. - Event-driven over polling. Prefer player callbacks, Flow emissions, and
snapshotFlow/collectchains to timer loops. Any unavoidable polling loop must suspend while paused, never busy-wait, and its interval must be justified against what actually changes at that rate. - Battery/background behaviour is part of every change. Dynamic
wakeMode(LOCAL foreground / NETWORK background audio) stays as is; no new wakelocks; no work keyed to the frame clock while the screen is off; WorkManager jobs must tolerate App Standby buckets.
- One fetch per cause. Every network call must be traceable to exactly one triggering event, deduped by id where re-triggering is possible. Effects in warm-kept trees fire on every key change even while invisible: gate their network side effects on visibility, or dedupe in the ViewModel.
- ViewModel scoping is a performance decision. A bare
hiltViewModel<MusicViewModel>()at a navigation route creates a fresh backStackEntry-scoped instance whoseinitre-runs the full home-load pipeline (~60 network calls) on every page open — this caused 30-second Daily Mix loads and device heat. Shared surfaces use the activity-scopedsharedMusicViewModel(); never reintroduce per-route instances of ViewModels with expensiveinit. New ViewModels must not fire network floods frominitat all — load lazily, cache, and refresh on staleness. - The shared pools are finite.
PerformanceDispatcher.networkIOis a fixed 4–16 thread pool. Launch parallel work as one bounded round (map { async { … } }.awaitAll()), never as an unbounded per-item fan-out, and never queue a second copy of a pipeline already in flight. - Flow lifecycle.
stateIn(WhileSubscribed(5_000))on UI-facing flows is load-bearing — subscription count gates work to actual UI visibility. Never switch toEagerly/Lazilyfor convenience, and never add hot collectors that outlive their surface. - Caches must be honored and invalidated. Check for an existing cache (music home cache, related-lane caches, Coil) before adding a fetch. New caches need explicit invalidation (TTL, region change, refresh) and must be read through, not around.
- Sustained heat while the app is open = per-frame work; drain with the screen off = CPU/network
loops. Diagnose in that order: (a) run the rule-2 audit over every composed-but-hidden tree;
(b) count fetches per user action in logcat — any unexplained second fetch is the bug;
(c) check
adb shell dumpsys gfxinfo io.github.aedev.flowfor continuous frame production while the UI should be idle; (d) only then suspect the player path. Do not "fix" heat by degrading visible design, motion, or update smoothness — find the invisible work instead.
- Never describe a change as faster, lighter, or cooler based on reasoning alone. State exactly
what was measured and what was not. Recommendation-engine changes require the offline benchmarks
(
MusicBenchmarkTest/NeuroBenchmarkTestregression floors) before and after; startup changes requireStartupBenchmarks, not baseline-profile size. - Player-path changes (ExoPlayer/Media3 setup, buffering config, surface handling, track selection) must not introduce added latency, buffering stalls, or black-screen/flicker regressions.
- The Compose basics still apply everywhere: hoist state, use
remember/derivedStateOf/rememberSaveablecorrectly, keep composable parameters stable/immutable, use lazy containers with stablekeys for any large collection, never block the main thread with I/O or DB/network work, and use structured concurrency with the correct dispatchers and cancellation.
This is the authoritative placement guide. It is not aspirational: ui/components/shared/,
ui/components/library/, ui/components/music/* and ui/components/layout/topbar/ are already
built this way, and new work must match them.
| Package | Holds | Visibility |
|---|---|---|
ui/screens/<feature>/ |
The route entry composable, its ViewModel, and pure state/policy helpers for that feature only | internal or private |
ui/components/<feature>/ |
Composables owned by one feature but reused across several screens or routes inside it | internal |
ui/components/shared/ |
Cross-feature building blocks with no feature knowledge | public |
ui/components/layout/ |
App chrome: top bars, scaffolds, navigation surfaces | public |
ui/theme/ |
Colour, type, shape and motion tokens. Every literal colour lives here | public |
ui/utils/ |
Compose-only helpers (modifiers, form factor, fading edges) | public |
utils/ |
Non-Compose helpers (formatters, parsers, dispatchers). Do not add a second utils package | public |
A file in ui/screens/ may not be imported by another feature's screen package. If two features
need it, it moves — that is the whole signal.
Answer these in order for any composable, and stop at the first "yes":
- Used only inside this one file, and under ~40 lines? Keep it
privatein the file. - Used by only one screen, but the screen file is over budget? Split it into a sibling file in
the same
ui/screens/<feature>/package,internal. - Used by two or more screens or routes of the same feature? Move it to
ui/components/<feature>/,internal. - Used by two or more different features, or encodes no feature vocabulary at all (a chip, a
badge, a row, an empty state, a search field, a thumbnail)? Move it to
ui/components/shared/,public, stripping every feature-specific parameter on the way.
Promote on the second real consumer, never on the first speculative one. A component with one
call site that "might be reused later" belongs next to its call site. Demote too — if a shared/
component ends up with a single caller after a refactor, move it back down.
Create ui/components/<feature>/ when the feature owns three or more component files, or when
one screen's components exceed ~600 lines in total. Sub-folder a feature package once it passes
eight files, splitting by role: card/, item/, row/, section/, header/, detail/,
sheet/, common/. Do not create utils/, misc/, helpers/ or other/ — a folder that
cannot be named by role is a folder that should not exist.
| File kind | Target | Hard ceiling |
|---|---|---|
Screen entry (*Screen.kt) |
250 lines | 400 |
| Component file | 300 lines | 500 |
| ViewModel | 400 lines | 600 |
| Anything else | 400 lines | 600 |
Over the ceiling, splitting is mandatory before the change lands. Under it, do not split a file you are not otherwise working in — this mirrors the Spotless ratchet: new and touched code meets the bar, untouched legacy code is left alone until someone has a reason to open it.
Split by responsibility, never by line count:
- Lift the leaves. Move the leaf composables out first; the screen keeps layout and state.
- Separate state from pixels. Pure functions (filtering, sorting, grouping, formatting) move to
their own file and become unit-testable —
HomePagination.kt,SubscriptionEnrichmentPolicy.ktandHomeUiStateNormalization.ktare the pattern to copy. - Group by surface. A screen with a list, a header and three sheets becomes
FooScreen.kt+FooHeader.kt+FooList.kt+FooSheets.kt. - Never split into
FooScreen2.kt,FooScreenPart2.ktorFooExtra.kt. Every file name must describe what is inside it.
shared/primitives with no domain meaning take theFlowprefix:FlowFilterChip,FlowSearchField,FlowEmptyState,FlowLoadingIndicator.shared/components in the media vocabulary take theMediaprefix:MediaRow,MediaThumbnail,MediaBadges,MediaKindSelector.- Feature components take the feature prefix:
LibraryShelf,MusicTrackItem,PlaylistDetailComponents,HistoryComponents. - The file is named after its primary export. One dominant export per file; a
*Components.ktor*Rows.ktfile may hold a small family of siblings and nothing else. - Private
const valareSCREAMING_SNAKE_CASE; privatevalholding aDp/Color/spec token stayPascalCase. ktlint enforces both.
- Identical output, different call sites → merge into one component. Delete the loser.
- Same shape, different values → merge, and expose the difference as a parameter whose default
preserves today's behaviour.
VideoCardFullWidth(useInternalPadding = true)is the reference: the default keeps every existing call site pixel-identical, and the container-padded screens opt out explicitly. - Same name, genuinely different purpose → leave both. Merging them produces a parameter bag nobody can read.
- Different design → stop. Consolidation is not a licence to change how anything looks.
A refactor that changes pixels is not a refactor. Visual and behavioural output must be identical before and after, unless the task explicitly asked for a visual change.
The route composable, its state hoisting, its effects and its layout. Not: bespoke cards, rows,
empty/error states, badges, formatters, or a second copy of a shared/ component.
- One ViewModel per feature, living in
ui/screens/<feature>/beside the screens it serves, annotated@HiltViewModelwith constructor injection. This co-location is intentional: a feature is a folder, and everything that belongs only to that feature lives in it. - A ViewModel shared by several routes is obtained through an explicit activity-scoped accessor
(
sharedMusicViewModel(),sharedMusicPlayerViewModel()), never a barehiltViewModel<T>()at each route. See performance rule 9 — this is a measured regression, not a style choice. - Each ViewModel owns its own
UiStatedata class, exposed as aStateFlowand mutated withMutableStateFlow.update { }fromkotlinx.coroutines. Thatupdateis the standard library extension, not a project API, and there is no central registry of states — each feature's state is local to that feature by design. To learn one feature's state, read that feature'sUiStateclass; it is the complete list for that screen. - There is deliberately no ViewModel superclass. A shared base class across ~70 ViewModels would couple every feature to one lifecycle and one state shape, and the project's warm-composition and activity-scoping rules mean the ViewModels genuinely do not share a lifecycle. Please open a discussion before proposing one.
- Pure logic (paging, filtering, normalization, policy) comes out of the ViewModel into its own file so it can be unit-tested without Android.
Flow uses Hilt, but some legacy app-owned classes are still reached through static getInstance()
calls. Treat those as migration debt, not as a pattern to copy.
- Use constructor injection by default for new ViewModels, repositories, use cases, workers, services and managers.
- Do not add new app-owned
getInstance()calls. (This does not apply to platform/library factories likeCalendar.getInstance()orWorkManager.getInstance().) - Migrate incrementally, only when a class is already in scope for your change. Do not perform a repository-wide DI rewrite as incidental cleanup.
- Preserve lifecycle and cardinality exactly. A migration must not create a second database, repository, cache, coroutine scope, network client, player, media session, or background service.
- Keep constructors and Hilt providers free of blocking I/O, network/database work, player preparation, and unrelated side effects.
- Do not create an interface for every class solely to claim SOLID compliance. Introduce an abstraction when it represents a real boundary or enables a useful fake.
- Player-path migration is high risk. Do not migrate
EnhancedPlayerManager,EnhancedMusicPlayerManager,ShortsPlayerPool, player services/media sessions, surfaces, caches, or their app-start initialization as opportunistic cleanup — it requires an explicit, separately agreed task. - DI is a maintainability tool, not a performance improvement. Do not claim a performance benefit without measurement.
- No dead code. Delete, never comment out. A moved component leaves nothing behind.
- Comments explain non-obvious WHY only — a workaround, a hidden constraint, a subtle invariant. Never restate WHAT the code already says. This is why the codebase looks sparsely commented; it is intentional, and adding descriptive comments is not the documentation improvement we are looking for.
- Moving a file makes it ratchet-eligible. Spotless
ratchetFromtreats a moved or touched file as new, so it must now pass ktlint in full, including import order and property naming. This is the most common cause of a surprise red build during a move. - Strings move with the component, and only in
values/strings.xml.
All user-facing strings must be declared in app/src/main/res/values/strings.xml and referenced
via stringResource(R.string.xxx) (or context.getString(...) outside Compose) — never inline
string literals in UI code. Add the string to strings.xml first, then reference it.
Do not touch other locales' strings.xml files. Translations are managed through Weblate; edit
only the default (English) resource file.
- Do not edit the Room database schema without agreeing it first — schema changes require a version bump and a migration, handled deliberately.
- Do not bump the app version in any file. Version bumps are done by the project owner.
- Do not touch non-English
strings.xmlfiles (Weblate owns them). - Do not regenerate the baseline profile for routine feature or UI work. It is a ~20 minute run on a physical device and produces thousands of lines of churn. It is regenerated when the cold-start path changes, when the generator's journeys change, or before a release.
Use the Conventional Commits format: type(scope): short description. Scope is optional but
preferred. Keep the subject in the imperative mood.
feat(player): add gapless playback
fix(music): recover the feed when a load-more page appends nothing
refactor(library): reorganise library components and share them across surfaces
perf(home): gate the marquee on sheet visibility
docs: document the ViewModel placement rules
test(neuro): add a regression floor for weak-tail coverage
chore(ci): limit concurrent Kotlin compilations
Types: feat, fix, perf, refactor, docs, test, build, ci, chore.
Older commits in the history use emoji prefixes (✨, 🐛, …). That convention is retired — please
use the type(scope): form for new work.
Spotless enforces ktlint formatting using the rules in .editorconfig, over Kotlin sources in
app/src/ and baselineprofile/src/ plus the selected Gradle Kotlin scripts.
Before committing or pushing Kotlin or Gradle Kotlin script changes:
./gradlew ktlintCheck # Windows: .\gradlew.bat ktlintCheckTo auto-format the files changed since the lint ratchet revision:
./gradlew ktlintFormat # Windows: .\gradlew.bat ktlintFormatReview the resulting diff before committing.
- Do not bypass, disable, or weaken the formatter to make a change pass. Fix the reported file,
or change
.editorconfigonly when the project convention itself is intentionally changing. - The repository adopts formatting incrementally with Spotless
ratchetFrom, so untouched legacy files are not reformatted. A newly added file, or an existing targeted file changed after the ratchet revision, must pass the configured ktlint rules in full. - GitHub Actions runs
spotlessCheckbefore tests and builds — a formatting violation fails theBuild APKjob.
Build the relevant flavor to check for compilation errors:
./gradlew :app:assembleGithubDebugThe minimum validation for a non-trivial change:
./gradlew ktlintCheck
./gradlew :app:testGithubDebugUnitTest
./gradlew :app:compileGithubDebugKotlin
./gradlew :app:compileFossDebugKotlinBoth flavors must compile — a change that only builds github will fail CI on foss.
- For UI changes, actually run the app on an emulator or device and exercise the golden path plus edge cases. A passing build does not mean the feature works.
- Add focused unit tests with any new pure logic (the state/policy files split out of ViewModels exist precisely so this is easy).
- Note in the PR description what you verified and what you did not. "Compiles and unit tests pass" is a fine thing to say; "behaviourally identical" is not, unless you exercised it.
-
Update your fork:
git fetch upstream git rebase upstream/main
-
Push your changes:
git push origin feature/your-feature-name
-
Create the Pull Request:
- Fill out the PR template
- Link any related issues
- State what you verified on-device, and what you did not
- If you hand-rolled anything (see "Use the platform"), say what you checked and what was missing
Keep pull requests focused. One concern per PR. A UI fix bundled with a refactor and a dependency bump takes three times as long to review and is three times as likely to be reverted.
- Maintainers will review your PR
- Address any feedback or requested changes
- Once approved, your PR will be merged
- Your contribution will be credited in releases
Flow is distributed through GitHub Releases and IzzyOnDroid. Both pin properties of the published artifacts, so the following are hard constraints. Breaking one of them cannot be fixed by a follow-up release — it forces every installed user to uninstall and reinstall, losing their local data.
The signing key never changes. Every release APK must be signed with the official key,
certificate SHA-256 4322294ed4caa2d4294140095818080ffe8acc1fbe3cdc76107df45c5286be40. Android
refuses to install an update signed by a different key. CI enforces this in the
Verify release signing certificate step, which fails the build on a mismatch. Never regenerate
release.keystore, and never rotate the RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, or
KEY_PASSWORD repository secrets.
Release asset file names are a public contract. IzzyOnDroid matches release assets by file name.
flow.apk and flow-foss.apk are the universal builds and must keep those exact names. The per-ABI
APKs are published alongside them as extras. Renaming or removing either universal APK silently
breaks the IzzyOnDroid update feed, so coordinate with IzzyOnDroid before changing them.
versionCode must increase on every release. F-Droid-format repositories use it to detect
updates. The ABI splits all share one versionCode, so the universal APK is the only artifact
IzzyOnDroid should consume.
A tag build must never publish unsigned APKs. app/build.gradle.kts falls back to
signingConfig = null when no keystore is present, which produces uninstallable APKs. CI hard-fails
a v* tag build when the keystore secret is missing rather than publishing them.
- Writing unit tests
- UI/UX improvements
- Performance optimization
- Bug fixes
- Accessibility features
- Translations (via Weblate, not by editing locale files directly)
- Be respectful and inclusive
- Welcome newcomers
- Give constructive feedback
- Focus on the code, not the person
- Help create a positive community
- Open a discussion — including questions about how the codebase works
- Comment on existing issues
- Reach out to maintainers
Every contribution helps make Flow better. Thank you for being part of the community!
Happy Coding! 🚀