Thanks for contributing to the Android client. This document captures the conventions that are not obvious from reading the code: the style gates, the JNI hot-path rules, and what CI enforces.
This project follows the Contributor Covenant.
By participating, you agree to uphold it. Report unacceptable behavior
to security@tinkernorth.com.
# 1) Install Android Studio Otter (2025.2)+ for AGP 9.2, NDK, CMake 3.22.1, JDK 17+
# 2) Open the project in Android Studio (Gradle sync downloads deps)
# 3) Point git at the in-tree pre-commit hook
scripts/setup-hooks.shThe pre-commit hook runs clang-format -i (autofix, re-stages) on staged
JNI C/C++ files and skips Kotlin to keep itself fast. CI runs clang-format --dry-run --Werror on the JNI plus ktlintCheck, detekt, and lint on
the Kotlin tree, so anything that slips locally fails the PR.
Every source file (*.kt, *.cpp, *.h) starts with:
// SPDX-License-Identifier: LGPL-3.0-or-later
// Copyright (C) 2026 Dish contributors.
New files must include both lines. Don't introduce code under a different
license: the project is LGPL-3.0-or-later end-to-end (LICENSE,
COPYING.GPL3, source headers).
- 4-space indent, ~120-column soft limit.
ktlintanddetektare authoritative: run./gradlew ktlintFormatto autofix and./gradlew detektto lint. MainViewModelexposes a single immutableMainUiStatevia aStateFlow. Don't introduce competing sources of truth: every UI-bound field belongs inMainUiState.- Coroutines for async,
kotlinx.serializationfor JSON, Hilt for DI. - Comments state non-obvious constraints only (why a lock order matters, what a magic value encodes). No narration of what the next line does. Older files are denser; new code follows this rule, not their example.
- All in-app navigation goes through
DishNavigator; raw Intents are for external targets (system settings, browsers) only. - Screens with reactive state or multi-step flows get a ViewModel exposing
one immutable UiState
StateFlow; static content screens may render directly. Derivation lives in composers/ViewModels, never in an Activity. - Views are defined in layout XML and inflated; no programmatic View
construction. Standard screens go through
BaseGamepadHostActivity.setScaffoldContent.
- C++17, four-space indent, 100-column soft limit. The same
.clang-formatships withsatellite,dish-linux, and this repo. Runclang-format -i app/src/main/cpp/*.{cpp,h}if you're unsure. - The JNI is the hot path. No allocations per packet, no JNI calls
from the input thread other than
sendReport, no logging on the per-event path.
- All changes land on
mainvia pull request: no direct pushes. - Use the PR template (
.github/pull_request_template.md) to describe the change, the manual test matrix you ran (real device + emulator), and call out anything that touches the wire protocol. - Keep commits focused; squash noisy fixup commits before review.
Build + style:
android-ci.yml:clang-formatoverapp/src/main/cpp/,./gradlew ktlintCheck,./gradlew detekt,./gradlew lint,./gradlew testDebugUnitTest,./gradlew assembleDebug(uploads the APK as a CI artifact).
Security gates (also blocking):
security.yml: action-pin lint, vulnerability allowlist expiry, OSV-Scanner, gitleaks secret scan, and GitHubdependency-review-action(consumes the Gradle dependency graph). Release artifacts additionally get a Grype scan inrelease.yml.codeql.yml: CodeQLjava-kotlinandcppanalysis (security-extended + security-and-quality query packs).
If any step fails, the PR is blocked.
Open a PR that adds an entry to .security/allowlist.yaml
(see the schema in the file). Required fields: cve, reason, owner,
expires. CI rejects the PR if any field is missing or expires is in
the past.
# Gradle dependency verification: recompute checksums into
# gradle/verification-metadata.xml after a deliberate dep bump
./gradlew --write-verification-metadata sha256 help
# Action-pin lint
grep -REn '^\s*uses:' .github/workflows/ \
| grep -vE '@[0-9a-f]{40}\b' \
|| echo "all pinned"
# Allowlist expiry
python3 - <<'PY'
import datetime, yaml, sys
data = yaml.safe_load(open('.security/allowlist.yaml').read()) or {}
for e in data.get('exceptions', []) or []:
if datetime.date.fromisoformat(str(e['expires'])) < datetime.date.today():
print('EXPIRED:', e); sys.exit(1)
PY
# OSV-Scanner
osv-scanner --recursive --skip-git .
# Gitleaks
gitleaks detect --no-banner --redact --source .The file is not committed yet. The section below describes the intended flow once a first generation pass lands; until then, running these commands on a clean checkout still produces a useful file you can diff as a review aid.
When you intentionally add or upgrade a dependency, regenerate the verification metadata so transitive jar tampering fails resolution:
./gradlew --write-verification-metadata sha256 \
assembleDebug bundleRelease testDebugUnitTestCommit the regenerated gradle/verification-metadata.xml in the same
PR as the dep bump. Reviewers should sanity-check the diff: a single
new dep should add ~1 line per transitive jar; a change to many
unrelated jars suggests something is off.
Each GitHub Release ships the signed dish-X.Y.Z.apk + .aab,
*.sig/*.crt (cosign keyless), SHA256SUMS + SHA256SUMS.sig/*.crt,
the SPDX + CycloneDX SBOMs, and dish-android.intoto.jsonl (SLSA L3).
sha256sum -c SHA256SUMS
cosign verify-blob \
--certificate SHA256SUMS.crt \
--signature SHA256SUMS.sig \
--certificate-identity-regexp '^https://github\.com/TinkerNorth/dish-android/\.github/workflows/release\.yml@refs/tags/v?[0-9].*$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
SHA256SUMS
slsa-verifier verify-artifact \
--provenance-path dish-android.intoto.jsonl \
--source-uri github.com/TinkerNorth/dish-android \
--source-tag X.Y.Z \
dish-X.Y.Z.apkThe full cross-repo verification recipe lives in
SECURITY.md.
The Kotlin → JNI → sendto() chain runs at gamepad polling rate and
must never block. If you're modifying MainActivity.dispatchGenericMotionEvent,
SatelliteNative, or the native input path (gamepad_input.cpp,
satellite_jni.cpp::sendReport):
- No
withContext, norunBlocking, noDispatchers.IOon the send path. - No allocations per event: use the preallocated
XUSB_REPORT. - The session map's lookup is the only lock allowed; hold it briefly.
- Preserve
IP_TOS = 0xB8(DSCP EF) andMSG_NOSIGNALon every send.
The Android, macOS, and Linux clients all talk to the same satellite
server and must produce byte-identical traffic:
- AEAD: ChaCha20-Poly1305 IETF, 12-byte big-endian nonce derived from a monotonic counter.
- Packet layout:
token(4) | counter(4) | ciphertext+tag, with the 4-byte token as AAD. - XUSB report: 12 bytes, little-endian.
- Ports: discovery UDP 9879 (broadcast) + mDNS, pairing + REST over HTTPS 9443, streaming UDP 9876.
The full opcode catalog and message layouts live in the protocol
contract, satellite/docs/contract.md, in the TinkerNorth/satellite
repo. The Android-side mapping is docs/contract.md.
Any change here must be coordinated with dish-linux, dish-mac, and
satellite in the same PR / release cycle.
Use the issue templates under .github/ISSUE_TEMPLATE/. Include the
device model, Android version, and adb logcat -s SatelliteJNI:* output
covering the misbehavior.