Resources
11Install
npx skillscat add windscribe/android-app Install via the SkillsCat registry.
Windscribe Android App — AI Agent Skill
You are an AI agent working with the Windscribe Android app codebase. This skill defines HOW to perform common development, debugging, and maintenance tasks.
For architecture reference (WHAT the system is), see AGENTS.md.
For human-friendly overview, see README.md.
Prerequisites
Before starting any work session:
# 1. Verify Android environment
echo $ANDROID_HOME
./gradlew --version
# 2. Pull latest changes (avoid conflicts)
git pull --rebase
# 3. Check current branch
git branch --show-current
# 4. Clean build if switching branches or after schema changes
./gradlew cleanDevelopment Workflows
Adding a New Screen (Mobile — Jetpack Compose)
Step 1: Define Screen Route
// mobile/src/main/java/com/windscribe/mobile/nav/Screen.kt
sealed class Screen(val route: String) {
// Existing screens...
object NewFeature: Screen("new_feature")
}Step 2: Add to Navigation Graph
// mobile/src/main/java/com/windscribe/mobile/nav/NavigationStack.kt
private fun NavGraphBuilder.addNavigationScreens() {
// Existing routes...
composable(
route = Screen.NewFeature.route,
enterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) {
ViewModelRoute(NewFeatureViewModel::class.java) {
NewFeatureScreen(it)
}
}
}Step 3: Create Compose Screen
// mobile/src/main/java/com/windscribe/mobile/ui/NewFeatureScreen.kt
@Composable
fun NewFeatureScreen(viewModel: NewFeatureViewModel) {
val state by viewModel.state.collectAsState()
when (state) {
is NewFeatureState.Loading -> LoadingIndicator()
is NewFeatureState.Success -> {
val data = (state as NewFeatureState.Success).data
Column {
Text("Feature Content: $data")
Button(onClick = { viewModel.performAction() }) {
Text("Action")
}
}
}
is NewFeatureState.Error -> {
ErrorMessage((state as NewFeatureState.Error).message)
}
}
}Step 4: Create Abstract ViewModel + Implementation
// mobile/src/main/java/com/windscribe/mobile/ui/NewFeatureViewModel.kt
// Abstract interface (allows easier testing)
abstract class NewFeatureViewModel : ViewModel() {
abstract val state: StateFlow<NewFeatureState>
abstract fun performAction()
}
// Implementation with dependencies
class NewFeatureViewModelImpl(
private val preferencesHelper: PreferencesHelper,
private val repository: SomeRepository
) : NewFeatureViewModel() {
private val _state = MutableStateFlow<NewFeatureState>(NewFeatureState.Loading)
override val state: StateFlow<NewFeatureState> = _state.asStateFlow()
init {
loadData()
}
private fun loadData() {
viewModelScope.launch {
_state.value = NewFeatureState.Loading
val data = repository.fetchData()
_state.value = NewFeatureState.Success(data)
}
}
override fun performAction() {
viewModelScope.launch {
// Perform action
}
}
}
// State definition
sealed class NewFeatureState {
object Loading : NewFeatureState()
data class Success(val data: String) : NewFeatureState()
data class Error(val message: String) : NewFeatureState()
}Step 5: Wire up Dagger Factory
// mobile/src/main/java/com/windscribe/mobile/di/ComposeModule.kt
@Module
class ComposeModule {
@Provides
fun getViewModelFactory(
preferencesHelper: PreferencesHelper,
repository: SomeRepository
// ... other dependencies
): ViewModelProvider.Factory {
return object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
// Existing ViewModels...
if (modelClass.isAssignableFrom(NewFeatureViewModel::class.java)) {
return NewFeatureViewModelImpl(preferencesHelper, repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}
}Step 6: Navigate to Screen
// From any Composable with access to navController
val navController = LocalNavController.current
Button(onClick = { navController.navigate(Screen.NewFeature.route) }) {
Text("Go to New Feature")
}Adding a Preference
Step 1: Define Constant
// base/src/main/java/com/windscribe/vpn/constants/PreferencesKeyConstants.kt
object PreferencesKeyConstants {
// Existing constants...
const val NEW_PREFERENCE = "new_preference_key"
}Step 2: Add to PreferencesHelper Interface
// base/src/main/java/com/windscribe/vpn/apppreference/PreferencesHelper.kt
@Singleton
interface PreferencesHelper {
// Existing properties...
var newPreference: String
}Step 3: Implement in AppPreferencesImpl
// base/src/main/java/com/windscribe/vpn/apppreference/AppPreferencesImpl.kt
@Singleton
class AppPreferencesImpl @Inject constructor(
private val appPreferences: TrayAppPreferences
) : PreferencesHelper {
// Existing implementations...
override var newPreference: String
get() = appPreferences.getString(PreferencesKeyConstants.NEW_PREFERENCE, "default_value")
set(value) = appPreferences.put(PreferencesKeyConstants.NEW_PREFERENCE, value)
}Step 4: Use in ViewModel
class SomeViewModel(
private val preferencesHelper: PreferencesHelper
) : ViewModel() {
fun updatePreference(value: String) {
preferencesHelper.newPreference = value
}
fun getPreference(): String {
return preferencesHelper.newPreference
}
}Adding a Repository Method
Step 1: Update DAO
// base/src/main/java/com/windscribe/vpn/localdatabase/dao/SomeDao.kt
@Dao
abstract class SomeDao {
@Query("SELECT * FROM SomeEntity WHERE id = :id")
abstract suspend fun getById(id: Int): SomeEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(entity: SomeEntity)
}Step 2: Add to LocalDbInterface
// base/src/main/java/com/windscribe/vpn/localdatabase/LocalDbInterface.kt
interface LocalDbInterface {
// Existing methods...
suspend fun getSomeEntityById(id: Int): SomeEntity?
suspend fun insertSomeEntity(entity: SomeEntity)
}Step 3: Implement in LocalDatabaseImpl
// base/src/main/java/com/windscribe/vpn/localdatabase/LocalDatabaseImpl.kt
class LocalDatabaseImpl @Inject constructor(
private val someDao: SomeDao,
// ... other DAOs
) : LocalDbInterface {
override suspend fun getSomeEntityById(id: Int): SomeEntity? {
return someDao.getById(id)
}
override suspend fun insertSomeEntity(entity: SomeEntity) {
someDao.insert(entity)
}
}Step 4: Add Repository Method
// base/src/main/java/com/windscribe/vpn/repository/SomeRepository.kt
class SomeRepository @Inject constructor(
private val scope: CoroutineScope,
private val apiCallManager: IApiCallManager,
private val localDbInterface: LocalDbInterface
) {
suspend fun fetchAndSaveEntity(id: Int): CallResult<SomeEntity> {
// Fetch from API
val apiResult = result<SomeEntityResponse> {
apiCallManager.getSomeEntity(id)
}
return when (apiResult) {
is CallResult.Success -> {
val entity = apiResult.data.toEntity()
localDbInterface.insertSomeEntity(entity)
CallResult.Success(entity)
}
is CallResult.Error -> apiResult
}
}
}Step 5: Use in ViewModel
class SomeViewModel(
private val repository: SomeRepository
) : ViewModel() {
fun loadEntity(id: Int) {
viewModelScope.launch {
when (val result = repository.fetchAndSaveEntity(id)) {
is CallResult.Success -> {
// Update UI state
}
is CallResult.Error -> {
// Show error
}
}
}
}
}Adding a VPN Feature
Pattern: Modify base → Update UI modules → Add tests
Step 1: Update Core Logic in base/backend
Example: Adding a new protocol option
// base/src/main/java/com/windscribe/vpn/backend/utils/WindVpnController.kt
class WindVpnController {
fun connectWithNewFeature(config: VPNConfig, enableFeature: Boolean) {
if (enableFeature) {
// Apply feature-specific configuration
config.customOption = "feature_value"
}
// Proceed with normal connection
connect(config)
}
}Step 2: Update Protocol Module (if needed)
If the feature requires native protocol changes:
// openvpn/src/main/java/com/windscribe/vpn/openvpn/OpenVPNManager.kt
class OpenVPNManager {
fun setCustomOption(value: String) {
nativeSetOption(value) // JNI call to C++
}
private external fun nativeSetOption(value: String)
}Step 3: Add Preference (if user-configurable)
Follow "Adding a Preference" workflow above.
Step 4: Update Mobile UI (Compose)
// mobile/src/main/java/com/windscribe/mobile/ui/SettingsScreen.kt
@Composable
fun SettingsScreen(viewModel: SettingsViewModel) {
Switch(
checked = viewModel.isNewFeatureEnabled.collectAsState().value,
onCheckedChange = { viewModel.setNewFeature(it) }
)
}Step 5: Update TV UI (XML)
<!-- tv/src/main/res/layout/settings_fragment.xml -->
<Switch
android:id="@+id/new_feature_switch"
android:text="Enable New Feature"
android:checked="@{viewModel.newFeatureEnabled}" />Step 6: Add Tests
// base/src/test/java/com/windscribe/vpn/backend/WindVpnControllerTest.kt
@Test
fun `connectWithNewFeature applies configuration when enabled`() {
val controller = WindVpnController()
val config = VPNConfig()
controller.connectWithNewFeature(config, enableFeature = true)
assertEquals("feature_value", config.customOption)
}Database Migration
When Needed: Adding/removing columns, changing types, adding tables
Step 1: Update Entity
// base/src/main/java/com/windscribe/vpn/localdatabase/entities/SomeEntity.kt
@Entity(tableName = "SomeEntity")
data class SomeEntity(
@PrimaryKey val id: Int,
val existingField: String,
val newField: String = "" // NEW FIELD
)Step 2: Increment Database Version
// base/src/main/java/com/windscribe/vpn/localdatabase/WindscribeDatabase.kt
@Database(
entities = [Region::class, City::class, SomeEntity::class],
version = 42, // INCREMENT THIS
exportSchema = true
)
abstract class WindscribeDatabase : RoomDatabase() {
// ...
}Step 3: Add Migration
// base/src/main/java/com/windscribe/vpn/localdatabase/WindscribeDatabase.kt
companion object {
val MIGRATION_41_42 = object : Migration(41, 42) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE SomeEntity ADD COLUMN newField TEXT NOT NULL DEFAULT ''"
)
}
}
fun getInstance(context: Context): WindscribeDatabase {
return Room.databaseBuilder(
context,
WindscribeDatabase::class.java,
"windscribe.db"
)
.addMigrations(
// ... existing migrations
MIGRATION_41_42
)
.build()
}
}Step 4: Test Migration
// base/src/androidTest/java/com/windscribe/vpn/localdatabase/MigrationTest.kt
@Test
fun migrate41To42() {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
WindscribeDatabase::class.java
)
// Create database at version 41
val db = helper.createDatabase("test.db", 41)
db.execSQL("INSERT INTO SomeEntity (id, existingField) VALUES (1, 'test')")
db.close()
// Run migration
helper.runMigrationsAndValidate("test.db", 42, true, MIGRATION_41_42)
// Verify new column exists
val migratedDb = helper.runMigrationsAndValidate("test.db", 42, true, MIGRATION_41_42)
val cursor = migratedDb.query("SELECT * FROM SomeEntity WHERE id = 1")
cursor.moveToFirst()
assertEquals("", cursor.getString(cursor.getColumnIndex("newField")))
}Build & Release
Building Different Variants
# Mobile — Google Play (default)
./gradlew :mobile:assembleGoogleDebug # Debug APK
./gradlew :mobile:assembleGoogleRelease # Release APK (requires signing)
./gradlew :mobile:bundleGoogleRelease # AAB for Play Store
# Mobile — F-Droid (no Google dependencies)
./gradlew :mobile:assembleFdroidDebug
./gradlew :mobile:assembleFdroidRelease
# TV — Google Play
./gradlew :tv:assembleGoogleDebug
./gradlew :tv:assembleGoogleRelease
# All modules, all variants
./gradlew assembleDebug
./gradlew assembleReleaseModule-Specific Compilation (Faster Iteration)
# Compile Kotlin only (no full APK build)
./gradlew :base:compileGoogleDebugKotlin
./gradlew :mobile:compileGoogleDebugKotlin
./gradlew :tv:compileGoogleDebugKotlin
# Compile all together
./gradlew :base:compileGoogleDebugKotlin :mobile:compileGoogleDebugKotlin :tv:compileGoogleDebugKotlin --console=plainRelease Checklist
See docs/workflows/RELEASE_PROCESS.md for full checklist.
Quick Reference:
- Update version in
build.gradle.kts(major.minor.build) - Update changelog
- Run full test suite (
./gradlew test connectedAndroidTest) - Test all 6 protocols on real devices
- Build release AAB (
./gradlew bundleGoogleRelease) - Sign and upload to Play Console
- Tag release in Git
Debugging
VPN Connection Issues
# Clear logcat buffer
"$ANDROID_HOME/platform-tools/adb" logcat -c
# Monitor VPN logs in real-time
"$ANDROID_HOME/platform-tools/adb" logcat -v time | grep -E "(WindVPN|OpenVPN|WireGuard|IKEv2)"
# Filter by specific protocol
"$ANDROID_HOME/platform-tools/adb" logcat -v time | grep -i "wireguard"
# Check connection state
"$ANDROID_HOME/platform-tools/adb" logcat -v time | grep "VPNConnectionState"
# Monitor network changes (auto-connect debugging)
"$ANDROID_HOME/platform-tools/adb" logcat -v time | grep "DeviceStateManager"Capture Screenshot (UI Debugging)
# Capture screenshot
"$ANDROID_HOME/platform-tools/adb" shell screencap -p /sdcard/screenshot.png
# Pull to local machine
"$ANDROID_HOME/platform-tools/adb" pull /sdcard/screenshot.png /tmp/screenshot.png
# Clean up
"$ANDROID_HOME/platform-tools/adb" shell rm /sdcard/screenshot.pngInspecting Room Database
# Pull database from device (requires root or debuggable app)
"$ANDROID_HOME/platform-tools/adb" pull /data/data/com.windscribe.vpn/databases/windscribe.db /tmp/
# Open with sqlite3
sqlite3 /tmp/windscribe.db
# Common queries
sqlite> .tables # List all tables
sqlite> .schema Region # Show table schema
sqlite> SELECT * FROM Region LIMIT 5; # View data
sqlite> SELECT COUNT(*) FROM City; # Count rowsDebugging Auto-Secure Whitelist
# Check whitelist state
"$ANDROID_HOME/platform-tools/adb" logcat -v time | grep -i "whitelist"
# Monitor network changes
"$ANDROID_HOME/platform-tools/adb" logcat -v time | grep "DeviceStateManager"
# Force network change (requires root)
"$ANDROID_HOME/platform-tools/adb" shell svc wifi disable
"$ANDROID_HOME/platform-tools/adb" shell svc wifi enableProtocol-Specific Debugging
OpenVPN:
"$ANDROID_HOME/platform-tools/adb" logcat | grep -i "openvpn"WireGuard:
"$ANDROID_HOME/platform-tools/adb" logcat | grep -i "wireguard"IKEv2:
"$ANDROID_HOME/platform-tools/adb" logcat | grep -i "ikev2\|strongswan"Build Failures
NDK Errors:
# Verify NDK installation
echo $ANDROID_NDK_HOME
# Clean and rebuild
./gradlew clean
./gradlew assembleDebugGradle Daemon Issues:
# Stop Gradle daemon
./gradlew --stop
# Clean and rebuild
./gradlew clean assembleDebugDatabase Migration Crash:
# Uninstall app (clears database)
"$ANDROID_HOME/platform-tools/adb" uninstall com.windscribe.vpn
# Reinstall
./gradlew :mobile:assembleGoogleDebug
"$ANDROID_HOME/platform-tools/adb" install -r mobile/build/outputs/apk/google/debug/mobile-google-debug.apkTesting
Running Tests
# All unit tests
./gradlew test
# Specific module
./gradlew :base:test
./gradlew :mobile:test
# Instrumented tests (requires device/emulator)
./gradlew connectedAndroidTest
# Specific test class
./gradlew :base:test --tests "WindVpnControllerTest"
# Test with coverage
./gradlew testDebugUnitTestCoverageWriting Unit Tests
Pattern: Arrange, Act, Assert (AAA)
class SomeRepositoryTest {
private lateinit var repository: SomeRepository
private lateinit var mockApiCallManager: IApiCallManager
private lateinit var mockLocalDb: LocalDbInterface
@Before
fun setup() {
mockApiCallManager = mockk()
mockLocalDb = mockk()
repository = SomeRepository(
scope = TestCoroutineScope(),
apiCallManager = mockApiCallManager,
localDbInterface = mockLocalDb
)
}
@Test
fun `updateServerList saves to database on success`() = runTest {
// Arrange
val mockResponse = ServerListResponse(regions = listOf(...))
coEvery { mockApiCallManager.getServerList(any()) } returns
GenericResponseClass(dataClass = mockResponse)
coEvery { mockLocalDb.addToRegions(any()) } just Runs
coEvery { mockLocalDb.getAllRegionAsync() } returns listOf(...)
// Act
val result = repository.updateServerList()
// Assert
assertTrue(result is CallResult.Success)
coVerify { mockLocalDb.addToRegions(mockResponse.regions) }
}
}Writing Instrumented Tests
@RunWith(AndroidJUnit4::class)
class DatabaseMigrationTest {
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
WindscribeDatabase::class.java
)
@Test
fun migrateAll() {
// Create database at version 1
helper.createDatabase("test.db", 1).apply {
close()
}
// Run all migrations up to current version
Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
WindscribeDatabase::class.java,
"test.db"
).build().apply {
openHelper.writableDatabase.close()
}
}
}Code Quality
Kotlin Linting
# Check code style (reports violations)
./gradlew ktlintCheck
# Auto-fix style issues
./gradlew ktlintFormat
# Run before every commit
./gradlew ktlintFormat && git add -ASecurity Scanning
Strix (Agentic red team):
# One-time comprehensive audit
strix scan /Users/gindersingh/Documents/Apps/gitlab/androidapp \
--output-format markdown \
--output-file docs/security/STRIX_AUDIT_$(date +%Y-%m-%d).md
# Quick scan (faster, less comprehensive)
strix scan --quick .Shannon (Vulnerability analysis):
# Analyze for vulnerabilities
shannon analyze /Users/gindersingh/Documents/Apps/gitlab/androidapp \
--report docs/security/SHANNON_AUDIT_$(date +%Y-%m-%d).mdOWASP Dependency Check:
# Check for known vulnerabilities in dependencies
./gradlew dependencyCheckAnalyze
# Report generated in build/reports/dependency-check-report.htmlGit Workflow
Commit Standards
Format: <type>(<scope>): <description>
Types:
feat: New featurefix: Bug fixrefactor: Code refactoring (no behavior change)perf: Performance improvementtest: Adding/updating testsdocs: Documentation changesbuild: Build system changesci: CI/CD changeschore: Maintenance tasks
Scopes:
mobile: Mobile UItv: TV UIbase: Core functionalityopenvpn: OpenVPN protocolwireguard: WireGuard protocolikev2: IKEv2 protocoldb: Database changesapi: API integration
Examples:
git commit -m "feat(mobile): add new settings screen for protocol selection"
git commit -m "fix(base): resolve auto-connect whitelist not clearing on network change"
git commit -m "refactor(wireguard): extract config parsing to separate class"
git commit -m "docs: update AGENTS.md with protocol switching workflow"Branch Naming
Pattern: <type>/<short-description>
# Features
git checkout -b feature/split-tunneling-ui
git checkout -b feature/wireguard-protocol
# Bug fixes
git checkout -b bugfix/connection-crash-on-wifi-change
git checkout -b bugfix/database-migration-38-39
# Hotfixes (for production issues)
git checkout -b hotfix/vpn-service-memory-leakCreating Pull Requests
Before creating PR:
# 1. Format code
./gradlew ktlintFormat
# 2. Run tests
./gradlew test
# 3. Commit changes
git add -A
git commit -m "feat(mobile): add feature X"
# 4. Push to remote
git push origin feature/my-feature
# 5. Create PR via GitLab UIPR Checklist (see docs/workflows/CODE_REVIEW_CHECKLIST.md):
- Code formatted (ktlintFormat)
- Tests pass (./gradlew test)
- New tests added for new features
- VPN features tested on all 6 protocols
- Database migration tested if schema changed
- No new Java files (use Kotlin)
- No direct API calls (use wsnet via ApiCallManager)
- Description explains WHAT and WHY
Critical Agent Rules
Always
Use Kotlin for ALL new code
- No new Java files
- Convert Java to Kotlin when modifying legacy code (if substantial change)
Use coroutines/flows for async operations
suspend funfor one-shot asyncFlow<T>for streamsStateFlow<T>for state- NO RxJava (fully removed)
Use wsnet for API calls
- NEVER use Retrofit/OkHttp directly
- All API calls via
ApiCallManager→wsnet
Run ktlintFormat before every commit
./gradlew ktlintFormat && git add -A && git commitTest VPN features on all 6 protocols
- OpenVPN UDP, OpenVPN TCP, IKEv2, Stealth, WSTunnel, WireGuard
- Protocol switching logic affects all
Update database schema properly
- Increment version in
WindscribeDatabase.kt - Add migration script
- Test migration with instrumented test
- Export schema to
schemas/folder
- Increment version in
Follow MVP architecture
- Activity/Fragment (View) → Presenter/ViewModel → Repository → API/Database
Inject via Dagger
- No manual
newfor singletons or core classes - Use
@Injectconstructor or@Providesmethods
- No manual
Write tests
- Unit tests for business logic (repositories, managers)
- Instrumented tests for database migrations and UI
Update CHANGELOG for user-facing changes
Never
Create circular dependencies
- ✅ mobile/tv → base → protocols
- ❌ base → mobile (breaks module hierarchy)
Use RxJava
- Fully removed from codebase
- Use coroutines/flows instead
Call APIs directly
- ❌
Retrofit.Builder()... - ✅
ApiCallManager.getServerList()
- ❌
Skip database migrations
- Will crash on app upgrade
- Always add migration for schema changes
Modify protocol modules without testing
- Test all 6 protocols if changing base/backend
- Protocol fallback logic depends on all working
Commit secrets/keys
- Use
BuildConfigfor build-time secrets - Use
local.propertiesfor developer keys (git-ignored) - No hardcoded API keys, tokens, passwords
- Use
Push directly to main/master
- Always use feature branches
- Create PR for review
Ignore ktlint violations
- CI will fail
- Run
ktlintFormatbefore committing
When Unsure
- Check CLAUDE.md for architectural patterns
- Check docs/guides/ for step-by-step workflows
- Search codebase for existing examples
# Find existing ViewModel implementations find . -name "*ViewModel.kt" | head -5 # Find Repository examples find . -name "*Repository.kt" | head -5 - Ask in PR if architectural decision needed
- Reference AGENTS.md for architecture overview
Common Pitfalls
Protocol Switching
Problem: Connection fails after switching protocols
Solution:
- Ensure old connection fully stopped before starting new
- Clear VPN interface state
- Wait for state machine to reach DISCONNECTED before reconnecting
// ❌ Don't do this
vpnBackend.stop()
vpnBackend.start(newConfig) // May fail if old connection not fully stopped
// ✅ Do this
vpnBackend.stop()
vpnBackend.waitForDisconnect(timeout = 5.seconds)
vpnBackend.start(newConfig)Auto-Secure Whitelist
Problem: Auto-connect not working after returning to network
Solution: Ensure whitelist is cleared on network change
// DeviceStateManager must clear whitelist when network changes
override fun onNetworkChanged(newNetwork: Network) {
clearAutoSecureWhitelist() // Critical!
checkAutoConnect()
}Database Migration
Problem: App crashes on upgrade with "Migration not found" error
Solution: Add migration for EVERY schema change
// ALWAYS add migration when incrementing version
@Database(version = 42) // Incremented from 41
abstract class WindscribeDatabase {
companion object {
val MIGRATION_41_42 = object : Migration(41, 42) {
override fun migrate(db: SupportSQLiteDatabase) {
// Migration SQL here
}
}
}
}Compose State
Problem: UI not updating when data changes
Solution: Ensure ViewModel uses StateFlow and UI collects as state
// ViewModel
private val _state = MutableStateFlow<State>(State.Loading)
val state: StateFlow<State> = _state.asStateFlow()
// Composable
val state by viewModel.state.collectAsState()Additional Resources
- AGENTS.md — Architecture reference (modules, patterns, flows, code examples)
- README.md — Build instructions, features, tech stack
- docs/guides/ — How-to guides (OpenVPN updates, testing, etc.)
- docs/architecture/ — Deep-dive architecture docs
- docs/workflows/ — Release process, debugging, code review
Last Updated: 2026-04-22
Maintained By: Engineering Team