- CommandReceiver: exported broadcast receiver for adb CLI control - start/stop/status/set_config commands - Usage: adb shell am broadcast -a com.sing.vpn.CMD -n com.sing.vpn/.CommandReceiver --es cmd start - SingConfig: add controlplane.tailscale.com → direct route rule to prevent tailnet control traffic from going through proxy
6.4 KiB
6.4 KiB
Android Code Style Guide
Language & Framework
- Kotlin only. No Java.
- Jetpack Compose for all UI (migration from XML in progress, see REDESIGN.md)
- Material 3 components via Compose Material3
- Min SDK 24, Target SDK 34
Project Structure
com.sing.vpn/
├── ui/
│ ├── theme/ # Color.kt, Type.kt, Theme.kt
│ ├── components/ # Reusable composables (StatusDot, SectionHeader, etc.)
│ ├── screens/ # Top-level screens (OverviewScreen, NodesScreen, ConfigScreen)
│ └── sheets/ # Bottom sheets (NodeActionSheet, ImportSheet)
├── data/ # Data models, storage, parsers
│ ├── Node.kt
│ ├── NodeStore.kt
│ ├── NodeParser.kt
│ └── SingConfig.kt
├── service/ # VPN service, JNI bridge
│ ├── SingVpnService.kt
│ ├── MiniSing.kt
│ └── BootReceiver.kt
└── MainActivity.kt # Single activity, Compose entry point
Kotlin Conventions
Naming
- Classes:
PascalCase—NodeStore,OverviewScreen - Functions:
camelCase—loadNodes(),formatBytes() - Composables:
PascalCase—@Composable fun NodeRow() - Constants:
SCREAMING_SNAKE—ROUTE_RULE,ACTION_START - State variables:
camelCasewith descriptive names —isConnected,activeNode
Functions
- Max 40 lines per function. Extract helpers if longer.
- Max 3 levels of nesting. Use early return /
when/letto flatten. - One responsibility per function. If the name has "and" in it, split it.
Error Handling
- Network calls:
try/catchwith silent failure for polling (traffic stats, peer list) - User-initiated actions: show error via Snackbar, never silent
- No
catch (e: Exception)without at least logging in debug builds - Never
!!— use?:,?.let, or explicit null checks
Threading
- IO work:
Dispatchers.IOvia coroutines (migrate from rawThread {}) - UI updates:
Dispatchers.Mainor Compose state (migrate fromrunOnUiThread) - No
Thread.sleep()in production code — usedelay()in coroutines - SharedPreferences reads are fine on main thread; writes use
.apply()not.commit()
Compose Rules
State
- Screen-level state in ViewModel or
remember {}/rememberSaveable {} - Lift state up: composables receive state as parameters, emit events as lambdas
- No
mutableStateOfinside composable body — useremember { mutableStateOf() }
// Good
@Composable
fun NodeRow(
node: Node,
isActive: Boolean,
delay: Int?,
onClick: () -> Unit
)
// Bad — internal state that should be lifted
@Composable
fun NodeRow(node: Node) {
var isExpanded by remember { mutableStateOf(false) } // OK for local-only UI state
val isActive = NodeStore.getActive() == node.id // Bad — side effect in composition
}
Layout
- Use
Modifieras first optional parameter - Use
Column/Row/Box, not nestedLayout - Prefer
Arrangement.spacedBy()over individual padding - Use
LazyColumnfor all lists, neverColumnwithforEach
Preview
- Every screen and reusable component must have a
@Preview - Preview with representative data, not empty state
@Preview(showBackground = true, backgroundColor = 0xFF0D0D0D)
@Composable
private fun NodeRowPreview() {
SingTheme {
NodeRow(
node = Node(name = "Tokyo-01", type = "vless", host = "1.2.3.4", port = 443),
isActive = true,
delay = 12,
onClick = {}
)
}
}
Theme
See REDESIGN.md for the full color palette. Key rules:
- Dark theme only (no light variant for now)
- Background
#0D0D0D, Surface#1A1A1A, Border#2A2A2A - Text hierarchy: primary
#EDEDEF, secondary#7C7C82, tertiary#4E4E52 - Status colors: green
#22C55E, yellow#EAB308, red#EF4444 - Accent: purple
#8B5CF6 - Technical data (IPs, latency, traffic) always in monospace
- No elevation/shadow — use border or background color difference for depth
Data Layer
SharedPreferences
- Single file:
"sing_settings" - Keys are string constants, not string literals scattered across code
- Read via extension functions, not raw
getString()everywhere
// Define keys in one place
object Prefs {
const val DNS_REMOTE = "dns_remote"
const val DNS_LOCAL = "dns_local"
const val ROUTING_MODE = "routing_mode"
// ...
}
Node Model
Nodeis a data class. Immutable after creation.NodeStorehandles persistence (SharedPreferences → migrate to Room if needed)NodeParserhandles all import formats (URI, subscription, JSON)SingConfig.generate()is the single source of truth for sing-box config
Network
- Clash API polling (
127.0.0.1:9090): useHttpURLConnection, 2s timeout, silent failure - Subscriptions:
HttpURLConnection, user-visible errors via Snackbar - No OkHttp or Retrofit — keep dependencies minimal
VPN Service
SingVpnServicemanages sing-box process lifecycle- Communication:
Intentactions (ACTION_START,ACTION_STOP,ACTION_SWITCH) - State broadcast:
LocalBroadcastManageror Flow (migrate from global broadcast) - TUN fd: duplicated via
ParcelFileDescriptor.dup().detachFd()before passing to engine - JNI bridge in
MiniSing.kt— keep it thin, no business logic
Dependencies
Minimal. Every dependency must justify its existence.
Allowed:
androidx.core,appcompat,fragment— Android essentialsandroidx.compose.*— UI frameworkcom.google.android.material:material— only for legacy components during migrationandroidx.recyclerview— only until Compose migration is completeorg.json— JSON (built-in, zero-cost)
Not allowed:
- OkHttp, Retrofit —
HttpURLConnectionis sufficient - Gson, Moshi, kotlinx.serialization —
org.jsonis fine for our scale - Dagger/Hilt/Koin — overkill for this app size
- Room — unless data model complexity demands it
Testing
- Unit tests for parsers (
NodeParser), config generation (SingConfig), utilities - UI tests with Compose testing framework (prefer over Espresso)
- E2E tests in
test-e2e.shfor full VPN flow - No mocking VPN service — test config generation and parsing, not the plumbing
Git
- Commit messages: English, imperative mood, one line
- Branch per feature:
feat/compose-overview,fix/dns-loop - No
--force-pushto main - PR = squash merge