diff --git a/.auto-claude-security.json b/.auto-claude-security.json index f740b91..419dee7 100644 --- a/.auto-claude-security.json +++ b/.auto-claude-security.json @@ -178,8 +178,8 @@ "cargo_aliases": [], "shell_scripts": [] }, - "project_dir": "D:\\AI\\Android\\APK\\Android-Apps\\apps\\wirelessadb", + "project_dir": "D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0005-connection-health-monitor", "created_at": "2026-02-05T12:56:49.824459", "project_hash": "0d6be561318e22002fe7a184fb411324", - "inherited_from": "D:\\AI\\Android\\APK\\Android-Apps\\apps\\wirelessadb" -} \ No newline at end of file + "inherited_from": "D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb" +} diff --git a/.auto-claude-status b/.auto-claude-status index 6fd3edb..f2e65be 100644 --- a/.auto-claude-status +++ b/.auto-claude-status @@ -1,25 +1,25 @@ -{ - "active": true, - "spec": "003-home-screen-widget", - "state": "building", - "subtasks": { - "completed": 11, - "total": 12, - "in_progress": 1, - "failed": 0 - }, - "phase": { - "current": "Integration & Testing", - "id": null, - "total": 2 - }, - "workers": { - "active": 0, - "max": 1 - }, - "session": { - "number": 13, - "started_at": "2026-02-07T01:52:14.093243" - }, - "last_update": "2026-02-07T02:38:46.645178" -} +{ + "active": true, + "spec": "005-connection-health-monitor", + "state": "building", + "subtasks": { + "completed": 11, + "total": 12, + "in_progress": 1, + "failed": 0 + }, + "phase": { + "current": "Integration & Testing", + "id": null, + "total": 2 + }, + "workers": { + "active": 0, + "max": 1 + }, + "session": { + "number": 14, + "started_at": "2026-02-07T02:58:43.952923" + }, + "last_update": "2026-02-07T03:45:20.171135" +} diff --git a/.claude_settings.json b/.claude_settings.json index 1ec7e75..25be76d 100644 --- a/.claude_settings.json +++ b/.claude_settings.json @@ -1,39 +1,47 @@ -{ - "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true - }, - "permissions": { - "defaultMode": "acceptEdits", - "allow": [ - "Read(./**)", - "Write(./**)", - "Edit(./**)", - "Glob(./**)", - "Grep(./**)", - "Read(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude\worktrees\tasks\003-home-screen-widget/**)", - "Write(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude\worktrees\tasks\003-home-screen-widget/**)", - "Edit(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude\worktrees\tasks\003-home-screen-widget/**)", - "Glob(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude\worktrees\tasks\003-home-screen-widget/**)", - "Grep(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude\worktrees\tasks\003-home-screen-widget/**)", - "Read(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude\worktrees\tasks\003-home-screen-widget\.auto-claude\specs\003-home-screen-widget/**)", - "Write(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude\worktrees\tasks\003-home-screen-widget\.auto-claude\specs\003-home-screen-widget/**)", - "Edit(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude\worktrees\tasks\003-home-screen-widget\.auto-claude\specs\003-home-screen-widget/**)", - "Read(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude/**)", - "Write(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude/**)", - "Edit(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude/**)", - "Glob(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude/**)", - "Grep(D:\AI\Android\APK\Android-Apps\apps\wirelessadb\.auto-claude/**)", - "Bash(*)", - "WebFetch(*)", - "WebSearch(*)", - "mcp__context7__resolve-library-id(*)", - "mcp__context7__get-library-docs(*)", - "mcp__graphiti-memory__search_nodes(*)", - "mcp__graphiti-memory__search_facts(*)", - "mcp__graphiti-memory__add_episode(*)", - "mcp__graphiti-memory__get_episodes(*)", - "mcp__graphiti-memory__get_entity_edge(*)" - ] - } -} +{ + "sandbox": { + "enabled": true, + "autoAllowBashIfSandboxed": true + }, + "permissions": { + "defaultMode": "acceptEdits", + "allow": [ + "Read(./**)", + "Write(./**)", + "Edit(./**)", + "Glob(./**)", + "Grep(./**)", + "Read(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0005-connection-health-monitor/**)", + "Write(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0005-connection-health-monitor/**)", + "Edit(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0005-connection-health-monitor/**)", + "Glob(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0005-connection-health-monitor/**)", + "Grep(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0005-connection-health-monitor/**)", + "Read(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0005-connection-health-monitor\\.auto-claude\\specs\u0005-connection-health-monitor/**)", + "Write(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0005-connection-health-monitor\\.auto-claude\\specs\u0005-connection-health-monitor/**)", + "Edit(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0005-connection-health-monitor\\.auto-claude\\specs\u0005-connection-health-monitor/**)", + "Read(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0003-home-screen-widget/**)", + "Write(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0003-home-screen-widget/**)", + "Edit(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0003-home-screen-widget/**)", + "Glob(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0003-home-screen-widget/**)", + "Grep(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0003-home-screen-widget/**)", + "Read(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0003-home-screen-widget\\.auto-claude\\specs\u0003-home-screen-widget/**)", + "Write(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0003-home-screen-widget\\.auto-claude\\specs\u0003-home-screen-widget/**)", + "Edit(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude\\worktrees\tasks\u0003-home-screen-widget\\.auto-claude\\specs\u0003-home-screen-widget/**)", + "Read(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude/**)", + "Write(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude/**)", + "Edit(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude/**)", + "Glob(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude/**)", + "Grep(D:\\AI\\Android\\APK\\Android-Apps\u0007pps\\wirelessadb\\.auto-claude/**)", + "Bash(*)", + "WebFetch(*)", + "WebSearch(*)", + "mcp__context7__resolve-library-id(*)", + "mcp__context7__get-library-docs(*)", + "mcp__graphiti-memory__search_nodes(*)", + "mcp__graphiti-memory__search_facts(*)", + "mcp__graphiti-memory__add_episode(*)", + "mcp__graphiti-memory__get_episodes(*)", + "mcp__graphiti-memory__get_entity_edge(*)" + ] + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5309e47..5a8022a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,138 +1,141 @@ -import java.util.Properties -import java.io.FileInputStream - -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") -} - -// Load .env properties for signing -val envFile = rootProject.file(".env") -val envProps = Properties().apply { - if (envFile.exists()) { - load(FileInputStream(envFile)) - } -} - -// CI/CD signing support (from GitHub Actions -P parameters) -val ciKeystoreFile: String? by project -val ciKeystorePassword: String? by project -val ciKeyAlias: String? by project -val ciKeyPassword: String? by project - -android { - namespace = "com.phenix.wirelessadb" - compileSdk = 34 - - defaultConfig { - applicationId = "com.phenix.wirelessadb" - minSdk = 26 - targetSdk = 34 - versionCode = 5 - versionName = "1.2.0" - } - - signingConfigs { - create("release") { - // CI/CD builds (GitHub Actions) take precedence - if (ciKeystoreFile != null) { - storeFile = file(ciKeystoreFile!!) - storePassword = ciKeystorePassword ?: "" - keyAlias = ciKeyAlias ?: "phenkey" - keyPassword = ciKeyPassword ?: "" - } else { - // Local builds from .env file - storeFile = file(envProps.getProperty("KEYSTORE_FILE", "../rootadb.keystore")) - storePassword = envProps.getProperty("KEYSTORE_PASSWORD", "") - keyAlias = envProps.getProperty("KEY_ALIAS", "phenkey") - keyPassword = envProps.getProperty("KEY_PASSWORD", "") - } - } - } - - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - signingConfig = signingConfigs.getByName("release") - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - buildFeatures { - viewBinding = true - buildConfig = true - aidl = true - } -} - -dependencies { - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.11.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") - - // TCP Relay Server - implementation("io.ktor:ktor-network:2.3.7") - - // JSON for trusted device storage - implementation("com.google.code.gson:gson:2.10.1") - - // Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") - - // LocalBroadcastManager - implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") - - // ViewPager2 for tabs - implementation("androidx.viewpager2:viewpager2:1.0.0") - - // Fragment with Kotlin extensions - implementation("androidx.fragment:fragment-ktx:1.6.2") - - // Activity with Kotlin extensions (for viewModels delegate) - implementation("androidx.activity:activity-ktx:1.8.2") - - // ViewModel - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") - - // JSch for SSH tunneling (Warpgate) - Updated to maintained fork - implementation("com.github.mwiede:jsch:2.27.7") - - // Shizuku for non-root privileged access - implementation("dev.rikka.shizuku:api:13.1.5") - implementation("dev.rikka.shizuku:provider:13.1.5") - - // Conscrypt for TLS 1.3 support (ADB pairing) - implementation("org.conscrypt:conscrypt-android:2.5.2") - - // ZXing for QR code generation - implementation("com.google.zxing:core:3.5.2") - - // ZXing Android Embedded for QR code scanning - implementation("com.journeyapps:zxing-android-embedded:4.3.0") - - // Testing - testImplementation("junit:junit:4.13.2") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") - testImplementation("io.mockk:mockk:1.13.9") - testImplementation("org.robolectric:robolectric:4.11.1") - testImplementation("androidx.test:core:1.5.0") - testImplementation("androidx.arch.core:core-testing:2.2.0") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") -} +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +// Load .env properties for signing +val envFile = rootProject.file(".env") +val envProps = Properties().apply { + if (envFile.exists()) { + load(FileInputStream(envFile)) + } +} + +// CI/CD signing support (from GitHub Actions -P parameters) +val ciKeystoreFile: String? by project +val ciKeystorePassword: String? by project +val ciKeyAlias: String? by project +val ciKeyPassword: String? by project + +android { + namespace = "com.phenix.wirelessadb" + compileSdk = 34 + + defaultConfig { + applicationId = "com.phenix.wirelessadb" + minSdk = 26 + targetSdk = 34 + versionCode = 5 + versionName = "1.2.0" + } + + signingConfigs { + create("release") { + // CI/CD builds (GitHub Actions) take precedence + if (ciKeystoreFile != null) { + storeFile = file(ciKeystoreFile!!) + storePassword = ciKeystorePassword ?: "" + keyAlias = ciKeyAlias ?: "phenkey" + keyPassword = ciKeyPassword ?: "" + } else { + // Local builds from .env file + storeFile = file(envProps.getProperty("KEYSTORE_FILE", "../rootadb.keystore")) + storePassword = envProps.getProperty("KEYSTORE_PASSWORD", "") + keyAlias = envProps.getProperty("KEY_ALIAS", "phenkey") + keyPassword = envProps.getProperty("KEY_PASSWORD", "") + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + buildConfig = true + aidl = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + // TCP Relay Server + implementation("io.ktor:ktor-network:2.3.7") + + // JSON for trusted device storage + implementation("com.google.code.gson:gson:2.10.1") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // LocalBroadcastManager + implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") + + // ViewPager2 for tabs + implementation("androidx.viewpager2:viewpager2:1.0.0") + + // Fragment with Kotlin extensions + implementation("androidx.fragment:fragment-ktx:1.6.2") + + // Activity with Kotlin extensions (for viewModels delegate) + implementation("androidx.activity:activity-ktx:1.8.2") + + // ViewModel + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") + + // WorkManager for background tasks + implementation("androidx.work:work-runtime-ktx:2.9.0") + + // JSch for SSH tunneling (Warpgate) - Updated to maintained fork + implementation("com.github.mwiede:jsch:2.27.7") + + // Shizuku for non-root privileged access + implementation("dev.rikka.shizuku:api:13.1.5") + implementation("dev.rikka.shizuku:provider:13.1.5") + + // Conscrypt for TLS 1.3 support (ADB pairing) + implementation("org.conscrypt:conscrypt-android:2.5.2") + + // ZXing for QR code generation + implementation("com.google.zxing:core:3.5.2") + + // ZXing Android Embedded for QR code scanning + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + + // Testing + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("io.mockk:mockk:1.13.9") + testImplementation("org.robolectric:robolectric:4.11.1") + testImplementation("androidx.test:core:1.5.0") + testImplementation("androidx.arch.core:core-testing:2.2.0") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} diff --git a/app/src/main/java/com/phenix/wirelessadb/AdbService.kt b/app/src/main/java/com/phenix/wirelessadb/AdbService.kt index 6aeeb8d..bb51289 100644 --- a/app/src/main/java/com/phenix/wirelessadb/AdbService.kt +++ b/app/src/main/java/com/phenix/wirelessadb/AdbService.kt @@ -31,7 +31,9 @@ class AdbService : Service() { companion object { private const val CHANNEL_ID = "adb_service_channel" + private const val HEALTH_CHANNEL_ID = "adb_health_channel" private const val NOTIFICATION_ID = 1001 + private const val HEALTH_NOTIFICATION_ID = 1002 const val ACTION_PENDING_AUTH = "com.phenix.wirelessadb.PENDING_AUTH" const val ACTION_APPROVE_DEVICE = "com.phenix.wirelessadb.APPROVE_DEVICE" const val ACTION_DENY_DEVICE = "com.phenix.wirelessadb.DENY_DEVICE" @@ -86,6 +88,19 @@ class AdbService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { + "com.phenix.wirelessadb.HEALTH_DEGRADATION" -> { + val healthStatus = intent.getStringExtra("health_status") ?: "UNKNOWN" + val degradationDetected = intent.getBooleanExtra("degradation_detected", false) + + val details = when { + healthStatus == "FAILED" -> "ADB connection is disabled or unreachable" + healthStatus == "DEGRADED" -> "ADB connection has changed (IP/port/network)" + else -> "Connection health issue detected" + } + + showHealthDegradationAlert(healthStatus, details) + return START_NOT_STICKY + } ACTION_APPROVE_DEVICE -> { val clientIp = intent.getStringExtra(EXTRA_CLIENT_IP) if (clientIp != null) { @@ -191,7 +206,10 @@ class AdbService : Service() { private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( + val manager = getSystemService(NotificationManager::class.java) + + // Main service channel + val serviceChannel = NotificationChannel( CHANNEL_ID, "ADB Service", NotificationManager.IMPORTANCE_LOW @@ -199,11 +217,43 @@ class AdbService : Service() { description = "Shows when Wireless ADB is active" setShowBadge(false) } - val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(channel) + manager.createNotificationChannel(serviceChannel) + + // Health notification channel + val healthChannel = NotificationChannel( + HEALTH_CHANNEL_ID, + "Connection Health", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Alerts about ADB connection health issues" + setShowBadge(true) + } + manager.createNotificationChannel(healthChannel) } } + fun showHealthDegradationAlert(healthStatus: String, details: String) { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, HEALTH_CHANNEL_ID) + .setContentTitle("ADB Connection Health: $healthStatus") + .setContentText(details) + .setStyle(NotificationCompat.BigTextStyle().bigText(details)) + .setSmallIcon(R.drawable.ic_notification) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + val manager = getSystemService(NotificationManager::class.java) + manager.notify(HEALTH_NOTIFICATION_ID, notification) + } + private fun createNotification( ip: String, port: Int, diff --git a/app/src/main/java/com/phenix/wirelessadb/PrefsManager.kt b/app/src/main/java/com/phenix/wirelessadb/PrefsManager.kt index 47a66f4..1bb8696 100644 --- a/app/src/main/java/com/phenix/wirelessadb/PrefsManager.kt +++ b/app/src/main/java/com/phenix/wirelessadb/PrefsManager.kt @@ -1,212 +1,246 @@ -package com.phenix.wirelessadb - -import android.content.Context -import android.content.SharedPreferences -import com.phenix.wirelessadb.model.ConnectionMode -import com.phenix.wirelessadb.theme.AccentColor -import com.phenix.wirelessadb.theme.ThemeMode -import com.phenix.wirelessadb.warpgate.WarpgateConfig - -object PrefsManager { - - private const val PREFS_NAME = "wireless_adb_prefs" - private const val KEY_ENABLE_ON_BOOT = "enable_on_boot" - private const val KEY_PORT = "port" - private const val KEY_RELAY_ENABLED = "relay_enabled" - private const val KEY_RELAY_PORT = "relay_port" - private const val DEFAULT_PORT = 5555 - private const val DEFAULT_RELAY_PORT = 5556 - - // Connection mode - private const val KEY_CONNECTION_MODE = "connection_mode" - - // Notification hiding - private const val KEY_HIDE_DEV_NOTIFICATION = "hide_dev_notification" - - // Theme settings (v1.2.0) - private const val KEY_THEME_MODE = "theme_mode" - private const val KEY_ACCENT_COLOR = "accent_color" - - // Warpgate settings - private const val KEY_WARPGATE_ENABLED = "warpgate_enabled" - private const val KEY_WARPGATE_HOST = "warpgate_host" - private const val KEY_WARPGATE_PORT = "warpgate_port" - private const val KEY_WARPGATE_USERNAME = "warpgate_username" - private const val KEY_WARPGATE_PASSWORD = "warpgate_password" - private const val KEY_WARPGATE_TARGET = "warpgate_target" - private const val KEY_WARPGATE_LOCAL_PORT = "warpgate_local_port" - - // Auto-reconnect settings - private const val KEY_AUTO_RECONNECT_ENABLED = "auto_reconnect_enabled" - private const val KEY_AUTO_RECONNECT_DELAY = "auto_reconnect_delay" - private const val KEY_AUTO_RECONNECT_MAX_RETRIES = "auto_reconnect_max_retries" - private const val DEFAULT_AUTO_RECONNECT_DELAY = 5000 // 5 seconds - private const val DEFAULT_AUTO_RECONNECT_MAX_RETRIES = 3 - - // Trusted networks settings - private const val KEY_TRUSTED_NETWORKS = "trusted_networks" - - private fun getPrefs(context: Context): SharedPreferences { - return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - } - - fun isEnableOnBoot(context: Context): Boolean { - return getPrefs(context).getBoolean(KEY_ENABLE_ON_BOOT, false) - } - - fun setEnableOnBoot(context: Context, enabled: Boolean) { - getPrefs(context).edit().putBoolean(KEY_ENABLE_ON_BOOT, enabled).apply() - } - - fun getPort(context: Context): Int { - return getPrefs(context).getInt(KEY_PORT, DEFAULT_PORT) - } - - fun setPort(context: Context, port: Int) { - getPrefs(context).edit().putInt(KEY_PORT, port).apply() - } - - fun isRelayEnabled(context: Context): Boolean { - return getPrefs(context).getBoolean(KEY_RELAY_ENABLED, false) - } - - fun setRelayEnabled(context: Context, enabled: Boolean) { - getPrefs(context).edit().putBoolean(KEY_RELAY_ENABLED, enabled).apply() - } - - fun getRelayPort(context: Context): Int { - return getPrefs(context).getInt(KEY_RELAY_PORT, DEFAULT_RELAY_PORT) - } - - fun setRelayPort(context: Context, port: Int) { - getPrefs(context).edit().putInt(KEY_RELAY_PORT, port).apply() - } - - // Connection Mode - fun getConnectionMode(context: Context): ConnectionMode { - val name = getPrefs(context).getString(KEY_CONNECTION_MODE, ConnectionMode.LOCAL_WIFI.name) - return try { - ConnectionMode.valueOf(name ?: ConnectionMode.LOCAL_WIFI.name) - } catch (e: IllegalArgumentException) { - ConnectionMode.LOCAL_WIFI - } - } - - fun setConnectionMode(context: Context, mode: ConnectionMode) { - getPrefs(context).edit().putString(KEY_CONNECTION_MODE, mode.name).apply() - } - - // Developer Notification Hiding - fun isHideDevNotification(context: Context): Boolean { - return getPrefs(context).getBoolean(KEY_HIDE_DEV_NOTIFICATION, false) - } - - fun setHideDevNotification(context: Context, hidden: Boolean) { - getPrefs(context).edit().putBoolean(KEY_HIDE_DEV_NOTIFICATION, hidden).apply() - } - - // Warpgate Configuration - fun getWarpgateConfig(context: Context): WarpgateConfig { - val prefs = getPrefs(context) - return WarpgateConfig( - enabled = prefs.getBoolean(KEY_WARPGATE_ENABLED, false), - host = prefs.getString(KEY_WARPGATE_HOST, "") ?: "", - port = prefs.getInt(KEY_WARPGATE_PORT, WarpgateConfig.DEFAULT_PORT), - username = prefs.getString(KEY_WARPGATE_USERNAME, "") ?: "", - password = prefs.getString(KEY_WARPGATE_PASSWORD, "") ?: "", - targetName = prefs.getString(KEY_WARPGATE_TARGET, "adb") ?: "adb", - localPort = prefs.getInt(KEY_WARPGATE_LOCAL_PORT, WarpgateConfig.DEFAULT_LOCAL_PORT) - ) - } - - fun setWarpgateConfig(context: Context, config: WarpgateConfig) { - getPrefs(context).edit().apply { - putBoolean(KEY_WARPGATE_ENABLED, config.enabled) - putString(KEY_WARPGATE_HOST, config.host) - putInt(KEY_WARPGATE_PORT, config.port) - putString(KEY_WARPGATE_USERNAME, config.username) - putString(KEY_WARPGATE_PASSWORD, config.password) - putString(KEY_WARPGATE_TARGET, config.targetName) - putInt(KEY_WARPGATE_LOCAL_PORT, config.localPort) - apply() - } - } - - fun setWarpgateEnabled(context: Context, enabled: Boolean) { - getPrefs(context).edit().putBoolean(KEY_WARPGATE_ENABLED, enabled).apply() - } - - fun isWarpgateEnabled(context: Context): Boolean { - return getPrefs(context).getBoolean(KEY_WARPGATE_ENABLED, false) - } - - // Theme Mode (v1.2.0) - fun getThemeMode(context: Context): ThemeMode { - val ordinal = getPrefs(context).getInt(KEY_THEME_MODE, ThemeMode.DEFAULT.ordinal) - return ThemeMode.fromOrdinal(ordinal) - } - - fun setThemeMode(context: Context, mode: ThemeMode) { - getPrefs(context).edit().putInt(KEY_THEME_MODE, mode.ordinal).apply() - } - - // Accent Color (v1.2.0) - fun getAccentColor(context: Context): AccentColor { - val ordinal = getPrefs(context).getInt(KEY_ACCENT_COLOR, AccentColor.DEFAULT.ordinal) - return AccentColor.fromOrdinal(ordinal) - } - - fun setAccentColor(context: Context, color: AccentColor) { - getPrefs(context).edit().putInt(KEY_ACCENT_COLOR, color.ordinal).apply() - } - - // Auto-reconnect Configuration - fun isAutoReconnectEnabled(context: Context): Boolean { - return getPrefs(context).getBoolean(KEY_AUTO_RECONNECT_ENABLED, true) - } - - fun setAutoReconnectEnabled(context: Context, enabled: Boolean) { - getPrefs(context).edit().putBoolean(KEY_AUTO_RECONNECT_ENABLED, enabled).apply() - } - - fun getAutoReconnectDelay(context: Context): Int { - return getPrefs(context).getInt(KEY_AUTO_RECONNECT_DELAY, DEFAULT_AUTO_RECONNECT_DELAY) - } - - fun setAutoReconnectDelay(context: Context, delay: Int) { - getPrefs(context).edit().putInt(KEY_AUTO_RECONNECT_DELAY, delay).apply() - } - - fun getAutoReconnectMaxRetries(context: Context): Int { - return getPrefs(context).getInt(KEY_AUTO_RECONNECT_MAX_RETRIES, DEFAULT_AUTO_RECONNECT_MAX_RETRIES) - } - - fun setAutoReconnectMaxRetries(context: Context, retries: Int) { - getPrefs(context).edit().putInt(KEY_AUTO_RECONNECT_MAX_RETRIES, retries).apply() - } - - // Trusted Networks - fun getTrustedNetworks(context: Context): Set { - return getPrefs(context).getStringSet(KEY_TRUSTED_NETWORKS, emptySet()) ?: emptySet() - } - - fun setTrustedNetworks(context: Context, networks: Set) { - getPrefs(context).edit().putStringSet(KEY_TRUSTED_NETWORKS, networks).apply() - } - - fun addTrustedNetwork(context: Context, ssid: String) { - val networks = getTrustedNetworks(context).toMutableSet() - networks.add(ssid) - setTrustedNetworks(context, networks) - } - - fun removeTrustedNetwork(context: Context, ssid: String) { - val networks = getTrustedNetworks(context).toMutableSet() - networks.remove(ssid) - setTrustedNetworks(context, networks) - } - - fun isTrustedNetwork(context: Context, ssid: String): Boolean { - return getTrustedNetworks(context).contains(ssid) - } -} +package com.phenix.wirelessadb + +import android.content.Context +import android.content.SharedPreferences +import com.phenix.wirelessadb.model.ConnectionMode +import com.phenix.wirelessadb.theme.AccentColor +import com.phenix.wirelessadb.theme.ThemeMode +import com.phenix.wirelessadb.warpgate.WarpgateConfig + +object PrefsManager { + + private const val PREFS_NAME = "wireless_adb_prefs" + private const val KEY_ENABLE_ON_BOOT = "enable_on_boot" + private const val KEY_PORT = "port" + private const val KEY_RELAY_ENABLED = "relay_enabled" + private const val KEY_RELAY_PORT = "relay_port" + private const val DEFAULT_PORT = 5555 + private const val DEFAULT_RELAY_PORT = 5556 + + // Connection mode + private const val KEY_CONNECTION_MODE = "connection_mode" + + // Notification hiding + private const val KEY_HIDE_DEV_NOTIFICATION = "hide_dev_notification" + + // Theme settings (v1.2.0) + private const val KEY_THEME_MODE = "theme_mode" + private const val KEY_ACCENT_COLOR = "accent_color" + + // Warpgate settings + private const val KEY_WARPGATE_ENABLED = "warpgate_enabled" + private const val KEY_WARPGATE_HOST = "warpgate_host" + private const val KEY_WARPGATE_PORT = "warpgate_port" + private const val KEY_WARPGATE_USERNAME = "warpgate_username" + private const val KEY_WARPGATE_PASSWORD = "warpgate_password" + private const val KEY_WARPGATE_TARGET = "warpgate_target" + private const val KEY_WARPGATE_LOCAL_PORT = "warpgate_local_port" + + // Health monitoring settings + private const val KEY_HEALTH_CHECK_INTERVAL = "health_check_interval" + private const val KEY_AUTO_RECONNECT_ENABLED = "auto_reconnect_enabled" + private const val KEY_MAX_RECONNECT_ATTEMPTS = "max_reconnect_attempts" + private const val KEY_RECONNECT_DELAY = "reconnect_delay" + private const val DEFAULT_HEALTH_CHECK_INTERVAL = 30000L // 30 seconds + private const val DEFAULT_MAX_RECONNECT_ATTEMPTS = 3 + private const val DEFAULT_RECONNECT_DELAY = 5000L // 5 seconds + + + // Auto-reconnect settings + private const val KEY_AUTO_RECONNECT_DELAY = "auto_reconnect_delay" + private const val KEY_AUTO_RECONNECT_MAX_RETRIES = "auto_reconnect_max_retries" + private const val DEFAULT_AUTO_RECONNECT_DELAY = 5000 // 5 seconds + private const val DEFAULT_AUTO_RECONNECT_MAX_RETRIES = 3 + // Trusted networks settings + private const val KEY_TRUSTED_NETWORKS = "trusted_networks" + + private fun getPrefs(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + fun isEnableOnBoot(context: Context): Boolean { + return getPrefs(context).getBoolean(KEY_ENABLE_ON_BOOT, false) + } + + fun setEnableOnBoot(context: Context, enabled: Boolean) { + getPrefs(context).edit().putBoolean(KEY_ENABLE_ON_BOOT, enabled).apply() + } + + fun getPort(context: Context): Int { + return getPrefs(context).getInt(KEY_PORT, DEFAULT_PORT) + } + + fun setPort(context: Context, port: Int) { + getPrefs(context).edit().putInt(KEY_PORT, port).apply() + } + + fun isRelayEnabled(context: Context): Boolean { + return getPrefs(context).getBoolean(KEY_RELAY_ENABLED, false) + } + + fun setRelayEnabled(context: Context, enabled: Boolean) { + getPrefs(context).edit().putBoolean(KEY_RELAY_ENABLED, enabled).apply() + } + + fun getRelayPort(context: Context): Int { + return getPrefs(context).getInt(KEY_RELAY_PORT, DEFAULT_RELAY_PORT) + } + + fun setRelayPort(context: Context, port: Int) { + getPrefs(context).edit().putInt(KEY_RELAY_PORT, port).apply() + } + + // Connection Mode + fun getConnectionMode(context: Context): ConnectionMode { + val name = getPrefs(context).getString(KEY_CONNECTION_MODE, ConnectionMode.LOCAL_WIFI.name) + return try { + ConnectionMode.valueOf(name ?: ConnectionMode.LOCAL_WIFI.name) + } catch (e: IllegalArgumentException) { + ConnectionMode.LOCAL_WIFI + } + } + + fun setConnectionMode(context: Context, mode: ConnectionMode) { + getPrefs(context).edit().putString(KEY_CONNECTION_MODE, mode.name).apply() + } + + // Developer Notification Hiding + fun isHideDevNotification(context: Context): Boolean { + return getPrefs(context).getBoolean(KEY_HIDE_DEV_NOTIFICATION, false) + } + + fun setHideDevNotification(context: Context, hidden: Boolean) { + getPrefs(context).edit().putBoolean(KEY_HIDE_DEV_NOTIFICATION, hidden).apply() + } + + // Warpgate Configuration + fun getWarpgateConfig(context: Context): WarpgateConfig { + val prefs = getPrefs(context) + return WarpgateConfig( + enabled = prefs.getBoolean(KEY_WARPGATE_ENABLED, false), + host = prefs.getString(KEY_WARPGATE_HOST, "") ?: "", + port = prefs.getInt(KEY_WARPGATE_PORT, WarpgateConfig.DEFAULT_PORT), + username = prefs.getString(KEY_WARPGATE_USERNAME, "") ?: "", + password = prefs.getString(KEY_WARPGATE_PASSWORD, "") ?: "", + targetName = prefs.getString(KEY_WARPGATE_TARGET, "adb") ?: "adb", + localPort = prefs.getInt(KEY_WARPGATE_LOCAL_PORT, WarpgateConfig.DEFAULT_LOCAL_PORT) + ) + } + + fun setWarpgateConfig(context: Context, config: WarpgateConfig) { + getPrefs(context).edit().apply { + putBoolean(KEY_WARPGATE_ENABLED, config.enabled) + putString(KEY_WARPGATE_HOST, config.host) + putInt(KEY_WARPGATE_PORT, config.port) + putString(KEY_WARPGATE_USERNAME, config.username) + putString(KEY_WARPGATE_PASSWORD, config.password) + putString(KEY_WARPGATE_TARGET, config.targetName) + putInt(KEY_WARPGATE_LOCAL_PORT, config.localPort) + apply() + } + } + + fun setWarpgateEnabled(context: Context, enabled: Boolean) { + getPrefs(context).edit().putBoolean(KEY_WARPGATE_ENABLED, enabled).apply() + } + + fun isWarpgateEnabled(context: Context): Boolean { + return getPrefs(context).getBoolean(KEY_WARPGATE_ENABLED, false) + } + + // Theme Mode (v1.2.0) + fun getThemeMode(context: Context): ThemeMode { + val ordinal = getPrefs(context).getInt(KEY_THEME_MODE, ThemeMode.DEFAULT.ordinal) + return ThemeMode.fromOrdinal(ordinal) + } + + fun setThemeMode(context: Context, mode: ThemeMode) { + getPrefs(context).edit().putInt(KEY_THEME_MODE, mode.ordinal).apply() + } + + // Accent Color (v1.2.0) + fun getAccentColor(context: Context): AccentColor { + val ordinal = getPrefs(context).getInt(KEY_ACCENT_COLOR, AccentColor.DEFAULT.ordinal) + return AccentColor.fromOrdinal(ordinal) + } + + fun setAccentColor(context: Context, color: AccentColor) { + getPrefs(context).edit().putInt(KEY_ACCENT_COLOR, color.ordinal).apply() + } + + // Health Monitoring + fun getHealthCheckInterval(context: Context): Long { + return getPrefs(context).getLong(KEY_HEALTH_CHECK_INTERVAL, DEFAULT_HEALTH_CHECK_INTERVAL) + } + + fun setHealthCheckInterval(context: Context, interval: Long) { + getPrefs(context).edit().putLong(KEY_HEALTH_CHECK_INTERVAL, interval).apply() + } + + fun isAutoReconnectEnabled(context: Context): Boolean { + return getPrefs(context).getBoolean(KEY_AUTO_RECONNECT_ENABLED, true) + } + + fun setAutoReconnectEnabled(context: Context, enabled: Boolean) { + getPrefs(context).edit().putBoolean(KEY_AUTO_RECONNECT_ENABLED, enabled).apply() + } + + fun getMaxReconnectAttempts(context: Context): Int { + return getPrefs(context).getInt(KEY_MAX_RECONNECT_ATTEMPTS, DEFAULT_MAX_RECONNECT_ATTEMPTS) + } + + fun setMaxReconnectAttempts(context: Context, attempts: Int) { + getPrefs(context).edit().putInt(KEY_MAX_RECONNECT_ATTEMPTS, attempts).apply() + } + + fun getReconnectDelay(context: Context): Long { + return getPrefs(context).getLong(KEY_RECONNECT_DELAY, DEFAULT_RECONNECT_DELAY) + } + + fun setReconnectDelay(context: Context, delay: Long) { + getPrefs(context).edit().putLong(KEY_RECONNECT_DELAY, delay).apply() + } + + + // Auto-reconnect Configuration + fun getAutoReconnectDelay(context: Context): Int { + return getPrefs(context).getInt(KEY_AUTO_RECONNECT_DELAY, DEFAULT_AUTO_RECONNECT_DELAY) + } + + fun setAutoReconnectDelay(context: Context, delay: Int) { + getPrefs(context).edit().putInt(KEY_AUTO_RECONNECT_DELAY, delay).apply() + } + + fun getAutoReconnectMaxRetries(context: Context): Int { + return getPrefs(context).getInt(KEY_AUTO_RECONNECT_MAX_RETRIES, DEFAULT_AUTO_RECONNECT_MAX_RETRIES) + } + + fun setAutoReconnectMaxRetries(context: Context, retries: Int) { + getPrefs(context).edit().putInt(KEY_AUTO_RECONNECT_MAX_RETRIES, retries).apply() + } + + // Trusted Networks + fun getTrustedNetworks(context: Context): Set { + return getPrefs(context).getStringSet(KEY_TRUSTED_NETWORKS, emptySet()) ?: emptySet() + } + + fun setTrustedNetworks(context: Context, networks: Set) { + getPrefs(context).edit().putStringSet(KEY_TRUSTED_NETWORKS, networks).apply() + } + + fun addTrustedNetwork(context: Context, ssid: String) { + val networks = getTrustedNetworks(context).toMutableSet() + networks.add(ssid) + setTrustedNetworks(context, networks) + } + + fun removeTrustedNetwork(context: Context, ssid: String) { + val networks = getTrustedNetworks(context).toMutableSet() + networks.remove(ssid) + setTrustedNetworks(context, networks) + } + + fun isTrustedNetwork(context: Context, ssid: String): Boolean { + return getTrustedNetworks(context).contains(ssid) + } +} diff --git a/app/src/main/java/com/phenix/wirelessadb/health/ConnectionHealthManager.kt b/app/src/main/java/com/phenix/wirelessadb/health/ConnectionHealthManager.kt new file mode 100644 index 0000000..9eb6212 --- /dev/null +++ b/app/src/main/java/com/phenix/wirelessadb/health/ConnectionHealthManager.kt @@ -0,0 +1,312 @@ +package com.phenix.wirelessadb.health + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.phenix.wirelessadb.AdbManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +/** + * ConnectionHealthManager coordinates health check scheduling and state management. + * + * This singleton manages periodic ADB health verification using WorkManager. + * It schedules HealthCheckWorker to run at configurable intervals and maintains + * the current health state for UI observation. + * + * Design: + * - Singleton pattern (object) following AdbManager/PrefsManager conventions + * - Uses WorkManager for battery-efficient periodic checks + * - Exposes LiveData for UI observation + * - Maintains last known ADB state to detect degradation + * + * @see HealthCheckWorker for health check implementation + * @see AdbManager for ADB status checking + */ +object ConnectionHealthManager { + + private const val TAG = "ConnectionHealthManager" + private const val WORK_NAME = "health_check_worker" + private const val DEFAULT_CHECK_INTERVAL_SECONDS = 30L + + /** + * Health state values for UI display and notification logic. + */ + enum class HealthState { + /** ADB is functioning normally */ + HEALTHY, + /** ADB connection has degraded (IP/port changed) */ + DEGRADED, + /** ADB is disabled or unreachable */ + FAILED, + /** Health monitoring is not active */ + UNKNOWN + } + + private val _healthState = MutableLiveData(HealthState.UNKNOWN) + val healthState: LiveData = _healthState + + private var lastKnownEnabled = false + private var lastKnownPort = 0 + private var lastKnownIp: String? = null + private var lastNotificationTime = 0L + private const val NOTIFICATION_COOLDOWN_MS = 5 * 60 * 1000L // 5 minutes + + /** + * Start health monitoring with the specified check interval. + * + * Schedules a PeriodicWorkRequest that runs HealthCheckWorker at the specified + * interval. If monitoring is already active, this will update the interval. + * + * @param context Application context + * @param intervalSeconds Time between health checks (default: 30 seconds, minimum: 15) + */ + suspend fun startMonitoring( + context: Context, + intervalSeconds: Long = DEFAULT_CHECK_INTERVAL_SECONDS + ) = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Starting health monitoring with interval: ${intervalSeconds}s") + + // Update last known state from current ADB status + val status = AdbManager.getStatus(context) + lastKnownEnabled = status.enabled + lastKnownPort = status.port + lastKnownIp = status.ip + Log.d(TAG, "Initial state: enabled=$lastKnownEnabled, port=$lastKnownPort, ip=$lastKnownIp") + + // Enforce minimum interval (WorkManager constraint: 15 minutes) + val minIntervalMinutes = 15L + val actualInterval = maxOf(intervalSeconds, minIntervalMinutes * 60) + if (actualInterval != intervalSeconds) { + Log.w(TAG, "Requested interval ${intervalSeconds}s below minimum, using ${actualInterval}s") + } + + // Build work request with constraints + val workRequest = PeriodicWorkRequestBuilder( + actualInterval, + TimeUnit.SECONDS + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // Run even without network + .setRequiresBatteryNotLow(false) // Run even on low battery (important for monitoring) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.LINEAR, + 15L * 60L * 1000L, // 15 minutes in milliseconds + TimeUnit.MILLISECONDS + ) + .setInputData( + workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to lastKnownEnabled, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to lastKnownPort, + HealthCheckWorker.KEY_LAST_KNOWN_IP to (lastKnownIp ?: "") + ) + ) + .addTag(WORK_NAME) + .build() + + // Schedule work (replace existing to update interval) + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + workRequest + ) + + // Set initial health state + _healthState.postValue(if (status.enabled) HealthState.HEALTHY else HealthState.FAILED) + + // Observe work info to update health state + observeWorkInfo(context) + + Log.d(TAG, "Health monitoring started successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to start health monitoring: ${e.message}", e) + _healthState.postValue(HealthState.UNKNOWN) + } + } + + /** + * Stop health monitoring. + * + * Cancels the scheduled PeriodicWorkRequest and resets health state to UNKNOWN. + * + * @param context Application context + */ + suspend fun stopMonitoring(context: Context) = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Stopping health monitoring") + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + _healthState.postValue(HealthState.UNKNOWN) + Log.d(TAG, "Health monitoring stopped") + } catch (e: Exception) { + Log.e(TAG, "Failed to stop health monitoring: ${e.message}", e) + } + } + + /** + * Check if health monitoring is currently active. + * + * @param context Application context + * @return true if monitoring is scheduled, false otherwise + */ + suspend fun isMonitoring(context: Context): Boolean = withContext(Dispatchers.IO) { + try { + val workInfos = WorkManager.getInstance(context) + .getWorkInfosForUniqueWork(WORK_NAME) + .get() + val isRunning = workInfos.any { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING } + Log.d(TAG, "isMonitoring: $isRunning") + isRunning + } catch (e: Exception) { + Log.e(TAG, "Failed to check monitoring status: ${e.message}", e) + false + } + } + + /** + * Update the last known ADB state. + * + * Should be called when ADB state changes intentionally (e.g., user enables/disables) + * to prevent false degradation alerts. + * + * @param enabled Whether ADB is enabled + * @param port ADB port + * @param ip Device IP address + */ + fun updateLastKnownState(enabled: Boolean, port: Int, ip: String?) { + Log.d(TAG, "Updating last known state: enabled=$enabled, port=$port, ip=$ip") + lastKnownEnabled = enabled + lastKnownPort = port + lastKnownIp = ip + } + + /** + * Observe WorkInfo updates to sync health state with worker results. + */ + private fun observeWorkInfo(context: Context) { + try { + WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkLiveData(WORK_NAME) + .observeForever { workInfos -> + if (workInfos.isNullOrEmpty()) return@observeForever + + val workInfo = workInfos.firstOrNull() ?: return@observeForever + if (workInfo.state != WorkInfo.State.SUCCEEDED) return@observeForever + + // Extract health status from output data + val healthStatus = workInfo.outputData.getString(HealthCheckWorker.KEY_HEALTH_STATUS) + val degradationDetected = workInfo.outputData.getBoolean( + HealthCheckWorker.KEY_DEGRADATION_DETECTED, + false + ) + + // Update state + val newState = when (healthStatus) { + HealthCheckWorker.HEALTH_HEALTHY -> HealthState.HEALTHY + HealthCheckWorker.HEALTH_DEGRADED -> HealthState.DEGRADED + HealthCheckWorker.HEALTH_FAILED -> HealthState.FAILED + else -> HealthState.UNKNOWN + } + + Log.d(TAG, "Health state updated: $newState (degradation=$degradationDetected)") + + // Trigger notification on degradation (only on state change) + val previousState = _healthState.value + if ((newState == HealthState.DEGRADED || newState == HealthState.FAILED) && + previousState != newState && + shouldShowNotification()) { + triggerHealthNotification(context, newState, degradationDetected) + } + + _healthState.postValue(newState) + + // Update last known state if healthy + if (newState == HealthState.HEALTHY) { + val currentEnabled = workInfo.outputData.getBoolean(HealthCheckWorker.KEY_CURRENT_ENABLED, false) + val currentPort = workInfo.outputData.getInt(HealthCheckWorker.KEY_CURRENT_PORT, 0) + val currentIp = workInfo.outputData.getString(HealthCheckWorker.KEY_CURRENT_IP) + updateLastKnownState(currentEnabled, currentPort, currentIp) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to observe work info: ${e.message}", e) + } + } + + /** + * Get the current health state value directly (non-LiveData). + * + * @return Current HealthState + */ + fun getCurrentHealthState(): HealthState { + return _healthState.value ?: HealthState.UNKNOWN + } + + /** + * Check if notification should be shown based on cooldown period. + * + * Prevents notification spam by enforcing a 5-minute cooldown between notifications. + * + * @return true if notification should be shown, false otherwise + */ + private fun shouldShowNotification(): Boolean { + val now = System.currentTimeMillis() + return if (now - lastNotificationTime > NOTIFICATION_COOLDOWN_MS) { + lastNotificationTime = now + true + } else { + Log.d(TAG, "Notification suppressed - cooldown active") + false + } + } + + /** + * Trigger health degradation notification. + * + * Sends intent to AdbService to display a notification about connection health issues. + * + * @param context Application context + * @param healthState Current health state (DEGRADED or FAILED) + * @param degradationDetected Whether degradation was detected + */ + private fun triggerHealthNotification( + context: Context, + healthState: HealthState, + degradationDetected: Boolean + ) { + try { + Log.d(TAG, "Triggering health notification: $healthState (degradation=$degradationDetected)") + + // Send intent to AdbService + val intent = Intent(context, com.phenix.wirelessadb.AdbService::class.java).apply { + action = "com.phenix.wirelessadb.HEALTH_DEGRADATION" + putExtra("health_status", healthState.name) + putExtra("degradation_detected", degradationDetected) + } + + // Start service to show notification + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to trigger health notification: ${e.message}", e) + } + } +} diff --git a/app/src/main/java/com/phenix/wirelessadb/health/HealthCheckWorker.kt b/app/src/main/java/com/phenix/wirelessadb/health/HealthCheckWorker.kt new file mode 100644 index 0000000..34d0742 --- /dev/null +++ b/app/src/main/java/com/phenix/wirelessadb/health/HealthCheckWorker.kt @@ -0,0 +1,146 @@ +package com.phenix.wirelessadb.health + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.phenix.wirelessadb.AdbManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * HealthCheckWorker verifies ADB connection status periodically. + * + * This worker runs every 30 seconds (configured by ConnectionHealthManager) + * and checks if ADB is still accessible and functional. It detects connection + * degradation and reports health status changes. + * + * Design: + * - Runs as a PeriodicWorkRequest scheduled by WorkManager + * - Calls AdbManager.getStatus() to verify current ADB state + * - Detects connection issues (enabled -> disabled, IP changes) + * - Battery efficient (uses WorkManager's optimized scheduling) + * + * @see ConnectionHealthManager for scheduling logic + * @see AdbManager.getStatus for ADB status checking + */ +class HealthCheckWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "HealthCheckWorker" + + // Input data keys + const val KEY_LAST_KNOWN_ENABLED = "last_known_enabled" + const val KEY_LAST_KNOWN_PORT = "last_known_port" + const val KEY_LAST_KNOWN_IP = "last_known_ip" + + // Output data keys + const val KEY_CURRENT_ENABLED = "current_enabled" + const val KEY_CURRENT_PORT = "current_port" + const val KEY_CURRENT_IP = "current_ip" + const val KEY_HEALTH_STATUS = "health_status" + const val KEY_DEGRADATION_DETECTED = "degradation_detected" + + // Health status values + const val HEALTH_HEALTHY = "healthy" + const val HEALTH_DEGRADED = "degraded" + const val HEALTH_FAILED = "failed" + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Starting ADB health check") + + // Get previous state from input data + val lastKnownEnabled = inputData.getBoolean(KEY_LAST_KNOWN_ENABLED, false) + val lastKnownPort = inputData.getInt(KEY_LAST_KNOWN_PORT, 0) + val lastKnownIp = inputData.getString(KEY_LAST_KNOWN_IP) + + Log.d(TAG, "Last known state: enabled=$lastKnownEnabled, port=$lastKnownPort, ip=$lastKnownIp") + + // Check current ADB status + val status = AdbManager.getStatus(applicationContext) + Log.d(TAG, "Current status: enabled=${status.enabled}, port=${status.port}, ip=${status.ip}") + + // Detect connection degradation + val degradationDetected = detectDegradation( + lastKnownEnabled = lastKnownEnabled, + lastKnownPort = lastKnownPort, + lastKnownIp = lastKnownIp, + currentEnabled = status.enabled, + currentPort = status.port, + currentIp = status.ip + ) + + // Determine health status + val healthStatus = when { + !status.enabled -> HEALTH_FAILED + degradationDetected -> HEALTH_DEGRADED + else -> HEALTH_HEALTHY + } + + Log.d(TAG, "Health check complete: status=$healthStatus, degradation=$degradationDetected") + + // Return success with output data + Result.success( + androidx.work.workDataOf( + KEY_CURRENT_ENABLED to status.enabled, + KEY_CURRENT_PORT to status.port, + KEY_CURRENT_IP to (status.ip ?: ""), + KEY_HEALTH_STATUS to healthStatus, + KEY_DEGRADATION_DETECTED to degradationDetected + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Health check failed: ${e.message}", e) + Result.failure() + } + } + + /** + * Detects if the ADB connection has degraded since the last check. + * + * Degradation scenarios: + * - ADB was enabled but is now disabled + * - Port changed unexpectedly + * - IP address changed (device switched networks) + * + * @return true if degradation detected, false otherwise + */ + private fun detectDegradation( + lastKnownEnabled: Boolean, + lastKnownPort: Int, + lastKnownIp: String?, + currentEnabled: Boolean, + currentPort: Int, + currentIp: String? + ): Boolean { + // If we've never seen ADB enabled, no degradation + if (!lastKnownEnabled) { + return false + } + + // ADB was enabled but is now disabled + if (lastKnownEnabled && !currentEnabled) { + Log.w(TAG, "Degradation: ADB was enabled but is now disabled") + return true + } + + // Port changed unexpectedly + if (lastKnownPort > 0 && lastKnownPort != currentPort) { + Log.w(TAG, "Degradation: Port changed from $lastKnownPort to $currentPort") + return true + } + + // IP changed (device switched networks or lost WiFi) + if (!lastKnownIp.isNullOrEmpty() && lastKnownIp != currentIp) { + Log.w(TAG, "Degradation: IP changed from $lastKnownIp to $currentIp") + return true + } + + return false + } +} diff --git a/app/src/main/java/com/phenix/wirelessadb/ui/ControlFragment.kt b/app/src/main/java/com/phenix/wirelessadb/ui/ControlFragment.kt index e3b1cf5..553d8fe 100644 --- a/app/src/main/java/com/phenix/wirelessadb/ui/ControlFragment.kt +++ b/app/src/main/java/com/phenix/wirelessadb/ui/ControlFragment.kt @@ -176,6 +176,14 @@ class ControlFragment : Fragment() { viewModel.showDevNotification() } } + + // Health monitoring switch + healthMonitoringSwitch.isChecked = viewModel.isAutoReconnectEnabled() + healthMonitoringSwitch.setOnCheckedChangeListener { _, checked -> + healthMonitoringSwitch.setStateDescription(getString(R.string.settings_health_monitoring), checked) + healthMonitoringSwitch.announceForAccessibility(getString(R.string.announcement_settings_changed)) + viewModel.setAutoReconnectEnabled(checked) + } } } @@ -418,6 +426,11 @@ class ControlFragment : Fragment() { viewModel.clearP2PError() } } + + // Connection Health + viewModel.healthState.observe(viewLifecycleOwner) { healthState -> + updateHealthIndicator(healthState) + } } private fun updateLocalConfigEnabled(hasRoot: Boolean) { @@ -571,6 +584,31 @@ class ControlFragment : Fragment() { } } + private fun updateHealthIndicator(healthState: com.phenix.wirelessadb.health.ConnectionHealthManager.HealthState) { + binding.apply { + when (healthState) { + com.phenix.wirelessadb.health.ConnectionHealthManager.HealthState.HEALTHY -> { + healthIcon.visibility = View.VISIBLE + healthIcon.setImageResource(R.drawable.ic_health) + healthIcon.contentDescription = "Connection health: healthy" + } + com.phenix.wirelessadb.health.ConnectionHealthManager.HealthState.DEGRADED -> { + healthIcon.visibility = View.VISIBLE + healthIcon.setImageResource(R.drawable.ic_health_warning) + healthIcon.contentDescription = "Connection health: degraded" + } + com.phenix.wirelessadb.health.ConnectionHealthManager.HealthState.FAILED -> { + healthIcon.visibility = View.VISIBLE + healthIcon.setImageResource(R.drawable.ic_health_warning) + healthIcon.contentDescription = "Connection health: failed" + } + com.phenix.wirelessadb.health.ConnectionHealthManager.HealthState.UNKNOWN -> { + healthIcon.visibility = View.GONE + } + } + } + } + private fun restartServiceIfNeeded() { val status = viewModel.adbStatus.value ?: return if (!status.enabled) return diff --git a/app/src/main/java/com/phenix/wirelessadb/viewmodel/AdbViewModel.kt b/app/src/main/java/com/phenix/wirelessadb/viewmodel/AdbViewModel.kt index 9e9240c..b5b5adb 100644 --- a/app/src/main/java/com/phenix/wirelessadb/viewmodel/AdbViewModel.kt +++ b/app/src/main/java/com/phenix/wirelessadb/viewmodel/AdbViewModel.kt @@ -1,345 +1,385 @@ -package com.phenix.wirelessadb.viewmodel - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import com.phenix.wirelessadb.AdbManager -import com.phenix.wirelessadb.PrefsManager -import com.phenix.wirelessadb.model.ConnectionMode -import com.phenix.wirelessadb.model.IndicatorState -import com.phenix.wirelessadb.model.StatusIndicators -import com.phenix.wirelessadb.relay.DeviceAuthManager -import com.phenix.wirelessadb.relay.TailscaleHelper -import com.phenix.wirelessadb.shell.ShellExecutor -import com.phenix.wirelessadb.util.NotificationHider -import com.phenix.wirelessadb.warpgate.WarpgateConfig -import com.phenix.wirelessadb.warpgate.WarpgateManager -import kotlinx.coroutines.launch - -class AdbViewModel(application: Application) : AndroidViewModel(application) { - - private val context = application.applicationContext - private val authManager = DeviceAuthManager(context) - - // ADB Status - private val _adbStatus = MutableLiveData() - val adbStatus: LiveData = _adbStatus - - // Root availability - private val _hasRoot = MutableLiveData() - val hasRoot: LiveData = _hasRoot - - // Loading state - private val _isLoading = MutableLiveData(false) - val isLoading: LiveData = _isLoading - - // Error messages - private val _error = MutableLiveData() - val error: LiveData = _error - - // Pending approval IP - private val _pendingApprovalIp = MutableLiveData() - val pendingApprovalIp: LiveData = _pendingApprovalIp - - // Trusted device count - private val _trustedDeviceCount = MutableLiveData(0) - val trustedDeviceCount: LiveData = _trustedDeviceCount - - // Status indicators for toolbar - private val _statusIndicators = MutableLiveData(StatusIndicators.DEFAULT) - val statusIndicators: LiveData = _statusIndicators - - // Current connection mode - private val _connectionMode = MutableLiveData(ConnectionMode.LOCAL_WIFI) - val connectionMode: LiveData = _connectionMode - - // Developer notification hidden state - private val _devNotificationHidden = MutableLiveData(false) - val devNotificationHidden: LiveData = _devNotificationHidden - - // Warpgate connection status - private val _warpgateConnected = MutableLiveData(false) - val warpgateConnected: LiveData = _warpgateConnected - - // P2P Manager and state (v1.2.0) - private val p2pManager by lazy { com.phenix.wirelessadb.p2p.P2PManager.getInstance(context) } - val p2pState: LiveData get() = p2pManager.connectionState - val p2pToken: LiveData get() = p2pManager.currentToken - val p2pError: LiveData get() = p2pManager.error - val p2pTunnelEndpoint: LiveData get() = p2pManager.tunnelEndpoint - - init { - initializeShellExecutor() - loadSavedPreferences() - checkRootAndRefresh() - } - - private fun initializeShellExecutor() { - viewModelScope.launch { - ShellExecutor.initialize() - } - } - - private fun loadSavedPreferences() { - _connectionMode.value = PrefsManager.getConnectionMode(context) - _devNotificationHidden.value = PrefsManager.isHideDevNotification(context) - } - - fun checkRootAndRefresh() { - viewModelScope.launch { - val rootAvailable = AdbManager.isRootAvailable() - _hasRoot.value = rootAvailable - if (rootAvailable) { - refreshStatus() - } - refreshStatusIndicators() - } - } - - fun refreshStatus() { - viewModelScope.launch { - val status = AdbManager.getStatus(context) - _adbStatus.value = status - _trustedDeviceCount.value = authManager.getTrustedDeviceCount() - refreshStatusIndicators() - } - } - - /** - * Refresh toolbar status indicators based on current state. - */ - fun refreshStatusIndicators() { - viewModelScope.launch { - val status = _adbStatus.value - val tailscaleIp = TailscaleHelper.getTailscaleIp(context) - val warpgateConfig = PrefsManager.getWarpgateConfig(context) - val p2pState = p2pManager.connectionState.value - - _statusIndicators.value = StatusIndicators( - localAdb = when { - status?.enabled == true && status.ip != null -> IndicatorState.ACTIVE - status?.enabled == true -> IndicatorState.WARNING - else -> IndicatorState.INACTIVE - }, - tailscale = if (tailscaleIp != null) IndicatorState.ACTIVE else IndicatorState.INACTIVE, - warpgate = when { - WarpgateManager.isConnected -> IndicatorState.ACTIVE - warpgateConfig.enabled -> IndicatorState.WARNING - else -> IndicatorState.INACTIVE - }, - p2p = when (p2pState) { - com.phenix.wirelessadb.p2p.P2PState.CONNECTED -> IndicatorState.ACTIVE - com.phenix.wirelessadb.p2p.P2PState.TOKEN_READY, - com.phenix.wirelessadb.p2p.P2PState.CONNECTING -> IndicatorState.WARNING - else -> IndicatorState.INACTIVE - } - ) - } - } - - fun toggleAdb(port: Int) { - viewModelScope.launch { - _isLoading.value = true - _error.value = null - - val currentStatus = _adbStatus.value - val isEnabled = currentStatus?.enabled == true - - val result = if (isEnabled) { - AdbManager.disable() - } else { - PrefsManager.setPort(context, port) - AdbManager.enable(port) - } - - result.onSuccess { - refreshStatus() - // Notify widgets and other components about ADB status change - com.phenix.wirelessadb.widget.AdbWidgetProvider.notifyStatusChanged(context) - }.onFailure { e -> - _error.value = e.message - } - - _isLoading.value = false - } - } - - fun setPendingApproval(ip: String?) { - _pendingApprovalIp.value = ip - } - - fun refreshTrustedCount() { - _trustedDeviceCount.value = authManager.getTrustedDeviceCount() - } - - fun clearError() { - _error.value = null - } - - // Connection Mode - fun setConnectionMode(mode: ConnectionMode) { - _connectionMode.value = mode - PrefsManager.setConnectionMode(context, mode) - refreshStatusIndicators() - } - - fun getConnectionMode(): ConnectionMode = _connectionMode.value ?: ConnectionMode.LOCAL_WIFI - - /** - * Get the appropriate ADB connect command based on current mode. - * Falls back to local command if preferred mode isn't available. - */ - fun getConnectionCommand(): String { - val status = _adbStatus.value ?: return "" - if (!status.enabled) return "" - - val mode = _connectionMode.value ?: ConnectionMode.LOCAL_WIFI - val tailscaleIp = TailscaleHelper.getTailscaleIp(context) - val localIp = status.ip - - // Try to get command for preferred mode, fallback to local if unavailable - return when (mode) { - ConnectionMode.LOCAL_WIFI -> { - localIp?.let { "adb connect $it:${status.port}" } ?: "" - } - ConnectionMode.TAILSCALE_DIRECT -> { - tailscaleIp?.let { "adb connect $it:${status.port}" } - ?: localIp?.let { "adb connect $it:${status.port}" } - ?: "" - } - ConnectionMode.TAILSCALE_RELAY -> { - tailscaleIp?.let { "adb connect $it:${PrefsManager.getRelayPort(context)}" } - ?: localIp?.let { "adb connect $it:${status.port}" } - ?: "" - } - ConnectionMode.WARPGATE -> { - WarpgateManager.getAdbCommand() - ?: localIp?.let { "adb connect $it:${status.port}" } - ?: "" - } - ConnectionMode.P2P_TOKEN -> { - // Use P2P tunnel endpoint when connected - p2pManager.getAdbCommand() - ?: localIp?.let { "adb connect $it:${status.port}" } - ?: "" - } - } - } - - // Developer Notification Hiding - fun hideDevNotification() { - viewModelScope.launch { - val result = NotificationHider.hideUsbDebuggingNotification() - result.onSuccess { - _devNotificationHidden.value = true - PrefsManager.setHideDevNotification(context, true) - }.onFailure { e -> - _error.value = "Failed to hide notification: ${e.message}" - } - } - } - - fun showDevNotification() { - viewModelScope.launch { - val result = NotificationHider.showUsbDebuggingNotification() - result.onSuccess { - _devNotificationHidden.value = false - PrefsManager.setHideDevNotification(context, false) - }.onFailure { e -> - _error.value = "Failed to show notification: ${e.message}" - } - } - } - - fun isNotificationHidingSupported(): Boolean = NotificationHider.isSupported() - - // Warpgate - fun connectWarpgate() { - viewModelScope.launch { - _isLoading.value = true - _error.value = null - - val config = PrefsManager.getWarpgateConfig(context) - val result = WarpgateManager.connect(config) - - result.onSuccess { - _warpgateConnected.value = true - refreshStatusIndicators() - }.onFailure { e -> - _error.value = "Warpgate connection failed: ${e.message}" - _warpgateConnected.value = false - } - - _isLoading.value = false - } - } - - fun disconnectWarpgate() { - WarpgateManager.disconnect() - _warpgateConnected.value = false - refreshStatusIndicators() - } - - fun getWarpgateConfig(): WarpgateConfig = PrefsManager.getWarpgateConfig(context) - - fun setWarpgateConfig(config: WarpgateConfig) { - PrefsManager.setWarpgateConfig(context, config) - refreshStatusIndicators() - } - - // Preferences - fun isEnableOnBoot(): Boolean = PrefsManager.isEnableOnBoot(context) - fun setEnableOnBoot(enabled: Boolean) = PrefsManager.setEnableOnBoot(context, enabled) - - fun isRelayEnabled(): Boolean = PrefsManager.isRelayEnabled(context) - fun setRelayEnabled(enabled: Boolean) = PrefsManager.setRelayEnabled(context, enabled) - - fun getPort(): Int = PrefsManager.getPort(context) - fun getRelayPort(): Int = PrefsManager.getRelayPort(context) - - // P2P Token methods (v1.2.0) - fun generateP2PToken() { - viewModelScope.launch { - p2pManager.generateToken() - refreshStatusIndicators() - } - } - - fun generatePersistentP2PToken() { - viewModelScope.launch { - p2pManager.generatePersistentToken() - refreshStatusIndicators() - } - } - - fun revokeP2PToken() { - p2pManager.revokeToken() - refreshStatusIndicators() - } - - fun connectWithP2PToken(tokenCode: String) { - viewModelScope.launch { - p2pManager.connectWithToken(tokenCode) - refreshStatusIndicators() - } - } - - fun disconnectP2P() { - p2pManager.disconnect() - refreshStatusIndicators() - } - - fun clearP2PError() { - p2pManager.clearError() - } - - override fun onCleared() { - super.onCleared() - // Disconnect Warpgate when ViewModel is cleared - WarpgateManager.disconnect() - // Cleanup P2P resources - p2pManager.destroy() - } -} +package com.phenix.wirelessadb.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.phenix.wirelessadb.AdbManager +import com.phenix.wirelessadb.PrefsManager +import com.phenix.wirelessadb.model.ConnectionMode +import com.phenix.wirelessadb.model.IndicatorState +import com.phenix.wirelessadb.model.StatusIndicators +import com.phenix.wirelessadb.relay.DeviceAuthManager +import com.phenix.wirelessadb.relay.TailscaleHelper +import com.phenix.wirelessadb.shell.ShellExecutor +import com.phenix.wirelessadb.util.NotificationHider +import com.phenix.wirelessadb.warpgate.WarpgateConfig +import com.phenix.wirelessadb.warpgate.WarpgateManager +import kotlinx.coroutines.launch + +class AdbViewModel(application: Application) : AndroidViewModel(application) { + + private val context = application.applicationContext + private val authManager = DeviceAuthManager(context) + + // ADB Status + private val _adbStatus = MutableLiveData() + val adbStatus: LiveData = _adbStatus + + // Root availability + private val _hasRoot = MutableLiveData() + val hasRoot: LiveData = _hasRoot + + // Loading state + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData = _isLoading + + // Error messages + private val _error = MutableLiveData() + val error: LiveData = _error + + // Pending approval IP + private val _pendingApprovalIp = MutableLiveData() + val pendingApprovalIp: LiveData = _pendingApprovalIp + + // Trusted device count + private val _trustedDeviceCount = MutableLiveData(0) + val trustedDeviceCount: LiveData = _trustedDeviceCount + + // Status indicators for toolbar + private val _statusIndicators = MutableLiveData(StatusIndicators.DEFAULT) + val statusIndicators: LiveData = _statusIndicators + + // Current connection mode + private val _connectionMode = MutableLiveData(ConnectionMode.LOCAL_WIFI) + val connectionMode: LiveData = _connectionMode + + // Developer notification hidden state + private val _devNotificationHidden = MutableLiveData(false) + val devNotificationHidden: LiveData = _devNotificationHidden + + // Warpgate connection status + private val _warpgateConnected = MutableLiveData(false) + val warpgateConnected: LiveData = _warpgateConnected + + // P2P Manager and state (v1.2.0) + private val p2pManager by lazy { com.phenix.wirelessadb.p2p.P2PManager.getInstance(context) } + val p2pState: LiveData get() = p2pManager.connectionState + val p2pToken: LiveData get() = p2pManager.currentToken + val p2pError: LiveData get() = p2pManager.error + val p2pTunnelEndpoint: LiveData get() = p2pManager.tunnelEndpoint + + // Connection Health Manager and state (v1.3.0) + val healthState: LiveData + get() = com.phenix.wirelessadb.health.ConnectionHealthManager.healthState + + init { + initializeShellExecutor() + loadSavedPreferences() + checkRootAndRefresh() + } + + private fun initializeShellExecutor() { + viewModelScope.launch { + ShellExecutor.initialize() + } + } + + private fun loadSavedPreferences() { + _connectionMode.value = PrefsManager.getConnectionMode(context) + _devNotificationHidden.value = PrefsManager.isHideDevNotification(context) + } + + fun checkRootAndRefresh() { + viewModelScope.launch { + val rootAvailable = AdbManager.isRootAvailable() + _hasRoot.value = rootAvailable + if (rootAvailable) { + refreshStatus() + } + refreshStatusIndicators() + } + } + + fun refreshStatus() { + viewModelScope.launch { + val status = AdbManager.getStatus(context) + _adbStatus.value = status + _trustedDeviceCount.value = authManager.getTrustedDeviceCount() + refreshStatusIndicators() + } + } + + /** + * Refresh toolbar status indicators based on current state. + */ + fun refreshStatusIndicators() { + viewModelScope.launch { + val status = _adbStatus.value + val tailscaleIp = TailscaleHelper.getTailscaleIp(context) + val warpgateConfig = PrefsManager.getWarpgateConfig(context) + val p2pState = p2pManager.connectionState.value + + _statusIndicators.value = StatusIndicators( + localAdb = when { + status?.enabled == true && status.ip != null -> IndicatorState.ACTIVE + status?.enabled == true -> IndicatorState.WARNING + else -> IndicatorState.INACTIVE + }, + tailscale = if (tailscaleIp != null) IndicatorState.ACTIVE else IndicatorState.INACTIVE, + warpgate = when { + WarpgateManager.isConnected -> IndicatorState.ACTIVE + warpgateConfig.enabled -> IndicatorState.WARNING + else -> IndicatorState.INACTIVE + }, + p2p = when (p2pState) { + com.phenix.wirelessadb.p2p.P2PState.CONNECTED -> IndicatorState.ACTIVE + com.phenix.wirelessadb.p2p.P2PState.TOKEN_READY, + com.phenix.wirelessadb.p2p.P2PState.CONNECTING -> IndicatorState.WARNING + else -> IndicatorState.INACTIVE + } + ) + } + } + + fun toggleAdb(port: Int) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + + val currentStatus = _adbStatus.value + val isEnabled = currentStatus?.enabled == true + + val result = if (isEnabled) { + AdbManager.disable() + } else { + PrefsManager.setPort(context, port) + AdbManager.enable(port) + } + + result.onSuccess { + refreshStatus() + + // Start/stop health monitoring based on new ADB state + if (!isEnabled && isAutoReconnectEnabled()) { + // ADB was just enabled and auto-reconnect is on + startHealthMonitoring() + } else if (isEnabled) { + // ADB was just disabled + stopHealthMonitoring() + } + // Notify widgets and other components about ADB status change + com.phenix.wirelessadb.widget.AdbWidgetProvider.notifyStatusChanged(context) + }.onFailure { e -> + _error.value = e.message + } + + _isLoading.value = false + } + } + + fun setPendingApproval(ip: String?) { + _pendingApprovalIp.value = ip + } + + fun refreshTrustedCount() { + _trustedDeviceCount.value = authManager.getTrustedDeviceCount() + } + + fun clearError() { + _error.value = null + } + + // Connection Mode + fun setConnectionMode(mode: ConnectionMode) { + _connectionMode.value = mode + PrefsManager.setConnectionMode(context, mode) + refreshStatusIndicators() + } + + fun getConnectionMode(): ConnectionMode = _connectionMode.value ?: ConnectionMode.LOCAL_WIFI + + /** + * Get the appropriate ADB connect command based on current mode. + * Falls back to local command if preferred mode isn't available. + */ + fun getConnectionCommand(): String { + val status = _adbStatus.value ?: return "" + if (!status.enabled) return "" + + val mode = _connectionMode.value ?: ConnectionMode.LOCAL_WIFI + val tailscaleIp = TailscaleHelper.getTailscaleIp(context) + val localIp = status.ip + + // Try to get command for preferred mode, fallback to local if unavailable + return when (mode) { + ConnectionMode.LOCAL_WIFI -> { + localIp?.let { "adb connect $it:${status.port}" } ?: "" + } + ConnectionMode.TAILSCALE_DIRECT -> { + tailscaleIp?.let { "adb connect $it:${status.port}" } + ?: localIp?.let { "adb connect $it:${status.port}" } + ?: "" + } + ConnectionMode.TAILSCALE_RELAY -> { + tailscaleIp?.let { "adb connect $it:${PrefsManager.getRelayPort(context)}" } + ?: localIp?.let { "adb connect $it:${status.port}" } + ?: "" + } + ConnectionMode.WARPGATE -> { + WarpgateManager.getAdbCommand() + ?: localIp?.let { "adb connect $it:${status.port}" } + ?: "" + } + ConnectionMode.P2P_TOKEN -> { + // Use P2P tunnel endpoint when connected + p2pManager.getAdbCommand() + ?: localIp?.let { "adb connect $it:${status.port}" } + ?: "" + } + } + } + + // Developer Notification Hiding + fun hideDevNotification() { + viewModelScope.launch { + val result = NotificationHider.hideUsbDebuggingNotification() + result.onSuccess { + _devNotificationHidden.value = true + PrefsManager.setHideDevNotification(context, true) + }.onFailure { e -> + _error.value = "Failed to hide notification: ${e.message}" + } + } + } + + fun showDevNotification() { + viewModelScope.launch { + val result = NotificationHider.showUsbDebuggingNotification() + result.onSuccess { + _devNotificationHidden.value = false + PrefsManager.setHideDevNotification(context, false) + }.onFailure { e -> + _error.value = "Failed to show notification: ${e.message}" + } + } + } + + fun isNotificationHidingSupported(): Boolean = NotificationHider.isSupported() + + // Warpgate + fun connectWarpgate() { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + + val config = PrefsManager.getWarpgateConfig(context) + val result = WarpgateManager.connect(config) + + result.onSuccess { + _warpgateConnected.value = true + refreshStatusIndicators() + }.onFailure { e -> + _error.value = "Warpgate connection failed: ${e.message}" + _warpgateConnected.value = false + } + + _isLoading.value = false + } + } + + fun disconnectWarpgate() { + WarpgateManager.disconnect() + _warpgateConnected.value = false + refreshStatusIndicators() + } + + fun getWarpgateConfig(): WarpgateConfig = PrefsManager.getWarpgateConfig(context) + + fun setWarpgateConfig(config: WarpgateConfig) { + PrefsManager.setWarpgateConfig(context, config) + refreshStatusIndicators() + } + + // Preferences + fun isEnableOnBoot(): Boolean = PrefsManager.isEnableOnBoot(context) + fun setEnableOnBoot(enabled: Boolean) = PrefsManager.setEnableOnBoot(context, enabled) + + fun isRelayEnabled(): Boolean = PrefsManager.isRelayEnabled(context) + fun setRelayEnabled(enabled: Boolean) = PrefsManager.setRelayEnabled(context, enabled) + + fun getPort(): Int = PrefsManager.getPort(context) + fun getRelayPort(): Int = PrefsManager.getRelayPort(context) + + // Health Monitoring (v1.3.0) + fun getHealthCheckInterval(): Long = PrefsManager.getHealthCheckInterval(context) + fun setHealthCheckInterval(interval: Long) = PrefsManager.setHealthCheckInterval(context, interval) + + fun isAutoReconnectEnabled(): Boolean = PrefsManager.isAutoReconnectEnabled(context) + fun setAutoReconnectEnabled(enabled: Boolean) { + PrefsManager.setAutoReconnectEnabled(context, enabled) + if (enabled && _adbStatus.value?.enabled == true) { + startHealthMonitoring() + } else { + stopHealthMonitoring() + } + } + + fun startHealthMonitoring() { + viewModelScope.launch { + val interval = getHealthCheckInterval() + com.phenix.wirelessadb.health.ConnectionHealthManager.startMonitoring(context, interval / 1000) + } + } + + fun stopHealthMonitoring() { + viewModelScope.launch { + com.phenix.wirelessadb.health.ConnectionHealthManager.stopMonitoring(context) + } + } + + // P2P Token methods (v1.2.0) + fun generateP2PToken() { + viewModelScope.launch { + p2pManager.generateToken() + refreshStatusIndicators() + } + } + + fun generatePersistentP2PToken() { + viewModelScope.launch { + p2pManager.generatePersistentToken() + refreshStatusIndicators() + } + } + + fun revokeP2PToken() { + p2pManager.revokeToken() + refreshStatusIndicators() + } + + fun connectWithP2PToken(tokenCode: String) { + viewModelScope.launch { + p2pManager.connectWithToken(tokenCode) + refreshStatusIndicators() + } + } + + fun disconnectP2P() { + p2pManager.disconnect() + refreshStatusIndicators() + } + + fun clearP2PError() { + p2pManager.clearError() + } + + override fun onCleared() { + super.onCleared() + // Disconnect Warpgate when ViewModel is cleared + WarpgateManager.disconnect() + // Cleanup P2P resources + p2pManager.destroy() + } +} diff --git a/app/src/main/res/drawable/ic_health.xml b/app/src/main/res/drawable/ic_health.xml new file mode 100644 index 0000000..11fd471 --- /dev/null +++ b/app/src/main/res/drawable/ic_health.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_health_warning.xml b/app/src/main/res/drawable/ic_health_warning.xml new file mode 100644 index 0000000..aca91f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_health_warning.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/layout/fragment_control.xml b/app/src/main/res/layout/fragment_control.xml index fb6d813..41ce260 100644 --- a/app/src/main/res/layout/fragment_control.xml +++ b/app/src/main/res/layout/fragment_control.xml @@ -63,6 +63,15 @@ android:layout_marginStart="12dp" android:accessibilityLiveRegion="polite" /> + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99f37b9..bb3f505 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,9 +34,7 @@ Connected - Enabled Disabled - Error Checking… @@ -144,6 +142,14 @@ Hide USB debugging notification Hides the system notification when ADB is enabled (requires root) + + Connection health monitoring + Monitor ADB connection and alert on issues + Health check interval + How often to check connection status + Toggle health monitoring + Health check interval setting + Shizuku Mode Using Shizuku for ADB control (limited features) @@ -229,23 +235,6 @@ P2P Connection Token Show QR Code - - Scan QR Code - Point your camera at a QR code to scan the pairing information - Camera permission required to scan QR codes - Camera permission denied. Please enable it in Settings. - Grant Permission - Failed to scan QR code - Invalid QR code format - Invalid QR code format - QR code scanned successfully - Processing QR code… - Scanning… - Scan QR Code - Scan QR Code - or scan QR code: - Cancel - Close Share @@ -287,6 +276,7 @@ IP address ADB connection command Status indicator icon + Connection health indicator Control tab @@ -347,11 +337,33 @@ Added: %1$s Requested: Now Remove device - + + + Scan QR Code + Point your camera at a QR code to scan the pairing information + Camera permission required to scan QR codes + Camera permission denied. Please enable it in Settings. + Grant Permission + Failed to scan QR code + Invalid QR code format + Invalid QR code format + QR code scanned successfully + Processing QR code… + Scanning… + Scan QR Code + Scan QR Code + or scan QR code: + Cancel + + Enabled + Error + + Quick ADB status and toggle control - + Wireless ADB Active on port %1$d Tap to enable + diff --git a/app/src/test/java/com/phenix/wirelessadb/health/ConnectionHealthManagerTest.kt b/app/src/test/java/com/phenix/wirelessadb/health/ConnectionHealthManagerTest.kt new file mode 100644 index 0000000..50b3cc4 --- /dev/null +++ b/app/src/test/java/com/phenix/wirelessadb/health/ConnectionHealthManagerTest.kt @@ -0,0 +1,237 @@ +package com.phenix.wirelessadb.health + +import android.content.Context +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.google.common.util.concurrent.ListenableFuture +import com.phenix.wirelessadb.AdbManager +import com.phenix.wirelessadb.health.ConnectionHealthManager.HealthState +import io.mockk.* +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class ConnectionHealthManagerTest { + + private lateinit var mockContext: Context + private lateinit var mockWorkManager: WorkManager + + @Before + fun setup() { + mockContext = mockk(relaxed = true) + mockWorkManager = mockk(relaxed = true) + + // Mock WorkManager singleton + mockkStatic(WorkManager::class) + every { WorkManager.getInstance(any()) } returns mockWorkManager + + // Mock AdbManager singleton + mockkObject(AdbManager) + + // Reset ConnectionHealthManager state + ConnectionHealthManager.updateLastKnownState(false, 0, null) + } + + @After + fun teardown() { + unmockkAll() + } + + // ============================================================ + // startMonitoring tests + // ============================================================ + + @Test + fun `testStartMonitoring_SchedulesWorkManager - schedules periodic work`() = runBlocking { + // Arrange + val adbStatus = AdbManager.AdbStatus(enabled = true, port = 5555, ip = "192.168.1.100") + coEvery { AdbManager.getStatus(any()) } returns adbStatus + + // Mock WorkManager methods + every { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) } returns mockk(relaxed = true) + every { mockWorkManager.getWorkInfosForUniqueWorkLiveData(any()) } returns mockk(relaxed = true) { + every { observeForever(any()) } just Runs + } + + // Act + ConnectionHealthManager.startMonitoring(mockContext, intervalSeconds = 30) + + // Assert + verify { + mockWorkManager.enqueueUniquePeriodicWork( + "health_check_worker", + any(), + any() + ) + } + } + + @Test + fun `testStartMonitoring_EnforcesMinimumInterval - enforces 15-minute minimum`() = runBlocking { + // Arrange + val adbStatus = AdbManager.AdbStatus(enabled = true, port = 5555, ip = "192.168.1.100") + coEvery { AdbManager.getStatus(any()) } returns adbStatus + + val capturedWorkRequest = slot() + every { + mockWorkManager.enqueueUniquePeriodicWork(any(), any(), capture(capturedWorkRequest)) + } returns mockk(relaxed = true) + every { mockWorkManager.getWorkInfosForUniqueWorkLiveData(any()) } returns mockk(relaxed = true) { + every { observeForever(any()) } just Runs + } + + // Act - request 30 seconds, should get 15 minutes (900 seconds) + ConnectionHealthManager.startMonitoring(mockContext, intervalSeconds = 30) + + // Assert - WorkManager enforces 15-minute minimum + // The actual interval will be 900 seconds (15 minutes) + verify { + mockWorkManager.enqueueUniquePeriodicWork( + "health_check_worker", + any(), + any() + ) + } + // Note: We can't directly verify the interval on the work request due to mockk limitations, + // but the code enforces the minimum in startMonitoring() + } + + // ============================================================ + // stopMonitoring tests + // ============================================================ + + @Test + fun `testStopMonitoring_CancelsWork - cancels WorkManager job`() = runBlocking { + // Arrange + every { mockWorkManager.cancelUniqueWork(any()) } returns mockk(relaxed = true) + + // Act + ConnectionHealthManager.stopMonitoring(mockContext) + + // Assert + verify { mockWorkManager.cancelUniqueWork("health_check_worker") } + } + + // ============================================================ + // isMonitoring tests + // ============================================================ + + @Test + fun `testIsMonitoring_ReturnsTrue_WhenActive - returns true when work is enqueued`() = runBlocking { + // Arrange + val mockWorkInfo = mockk { + every { state } returns WorkInfo.State.ENQUEUED + } + val mockFuture = mockk>> { + every { get() } returns listOf(mockWorkInfo) + } + every { mockWorkManager.getWorkInfosForUniqueWork(any()) } returns mockFuture + + // Act + val result = ConnectionHealthManager.isMonitoring(mockContext) + + // Assert + assertTrue(result) + } + + @Test + fun `testIsMonitoring_ReturnsFalse_WhenStopped - returns false when work is cancelled`() = runBlocking { + // Arrange + val mockWorkInfo = mockk { + every { state } returns WorkInfo.State.CANCELLED + } + val mockFuture = mockk>> { + every { get() } returns listOf(mockWorkInfo) + } + every { mockWorkManager.getWorkInfosForUniqueWork(any()) } returns mockFuture + + // Act + val result = ConnectionHealthManager.isMonitoring(mockContext) + + // Assert + assertFalse(result) + } + + // ============================================================ + // Health state tests + // ============================================================ + + @Test + fun `testHealthState_UpdatesWhenWorkerCompletes - health state reflects worker results`() = runBlocking { + // Arrange + val adbStatus = AdbManager.AdbStatus(enabled = true, port = 5555, ip = "192.168.1.100") + coEvery { AdbManager.getStatus(any()) } returns adbStatus + + every { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) } returns mockk(relaxed = true) + every { mockWorkManager.getWorkInfosForUniqueWorkLiveData(any()) } returns mockk(relaxed = true) { + every { observeForever(any()) } just Runs + } + + // Act + ConnectionHealthManager.startMonitoring(mockContext, intervalSeconds = 30) + + // Assert - initial state should be HEALTHY (since ADB is enabled) + // Note: This test verifies the initial state. Full LiveData testing would require + // instrumented tests or more complex mocking + val currentState = ConnectionHealthManager.getCurrentHealthState() + assertTrue(currentState == HealthState.HEALTHY || currentState == HealthState.UNKNOWN) + } + + // ============================================================ + // updateLastKnownState tests + // ============================================================ + + @Test + fun `testUpdateLastKnownState_StoresCorrectValues - stores last known ADB state`() { + // Act + ConnectionHealthManager.updateLastKnownState( + enabled = true, + port = 5555, + ip = "192.168.1.100" + ) + + // Assert - can't directly verify private fields, but we can verify no exceptions thrown + // This method is used internally and tested indirectly through startMonitoring + // Verify it doesn't throw exceptions + assertTrue(true) + } + + // ============================================================ + // observeWorkInfo integration test + // ============================================================ + + @Test + fun `testObserveWorkInfo_UpdatesHealthState - observes work completion and updates state`() = runBlocking { + // Arrange + val adbStatus = AdbManager.AdbStatus(enabled = true, port = 5555, ip = "192.168.1.100") + coEvery { AdbManager.getStatus(any()) } returns adbStatus + + // Mock LiveData observation + val mockLiveData = mockk>>(relaxed = true) { + every { observeForever(any()) } just Runs + } + every { mockWorkManager.getWorkInfosForUniqueWorkLiveData(any()) } returns mockLiveData + every { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) } returns mockk(relaxed = true) + + // Act + ConnectionHealthManager.startMonitoring(mockContext, intervalSeconds = 30) + + // Assert - verify observeForever was called (meaning we're observing work info) + verify { mockLiveData.observeForever(any()) } + } + + // ============================================================ + // getCurrentHealthState test + // ============================================================ + + @Test + fun `testGetCurrentHealthState_ReturnsCurrentValue - returns current health state value`() { + // Act + val state = ConnectionHealthManager.getCurrentHealthState() + + // Assert - default state should be UNKNOWN before monitoring starts + assertEquals(HealthState.UNKNOWN, state) + } +} diff --git a/app/src/test/java/com/phenix/wirelessadb/health/HealthCheckWorkerTest.kt b/app/src/test/java/com/phenix/wirelessadb/health/HealthCheckWorkerTest.kt new file mode 100644 index 0000000..1129bc0 --- /dev/null +++ b/app/src/test/java/com/phenix/wirelessadb/health/HealthCheckWorkerTest.kt @@ -0,0 +1,291 @@ +package com.phenix.wirelessadb.health + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.phenix.wirelessadb.AdbManager +import io.mockk.* +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class HealthCheckWorkerTest { + + private lateinit var mockContext: Context + private lateinit var mockParams: WorkerParameters + private lateinit var worker: HealthCheckWorker + + @Before + fun setup() { + mockContext = mockk(relaxed = true) + mockParams = mockk(relaxed = true) + + // Mock AdbManager singleton + mockkObject(AdbManager) + + worker = HealthCheckWorker(mockContext, mockParams) + } + + @After + fun teardown() { + unmockkAll() + } + + // ============================================================ + // Success scenario tests + // ============================================================ + + @Test + fun `testDoWork_Success - returns success when ADB is healthy`() = runBlocking { + // Arrange + val inputData = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to true, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 5555, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "192.168.1.100" + ) + every { mockParams.inputData } returns inputData + + val healthyStatus = AdbManager.AdbStatus( + enabled = true, + port = 5555, + ip = "192.168.1.100" + ) + coEvery { AdbManager.getStatus(any()) } returns healthyStatus + + // Act + val result = worker.doWork() + + // Assert + assertTrue(result is ListenableWorker.Result.Success) + val outputData = (result as ListenableWorker.Result.Success).outputData + assertEquals(true, outputData.getBoolean(HealthCheckWorker.KEY_CURRENT_ENABLED, false)) + assertEquals(5555, outputData.getInt(HealthCheckWorker.KEY_CURRENT_PORT, 0)) + assertEquals("192.168.1.100", outputData.getString(HealthCheckWorker.KEY_CURRENT_IP)) + assertEquals(HealthCheckWorker.HEALTH_HEALTHY, outputData.getString(HealthCheckWorker.KEY_HEALTH_STATUS)) + assertEquals(false, outputData.getBoolean(HealthCheckWorker.KEY_DEGRADATION_DETECTED, true)) + } + + // ============================================================ + // ADB disabled scenario + // ============================================================ + + @Test + fun `testDoWork_AdbDisabled_ReturnsFailed - returns FAILED when ADB is disabled`() = runBlocking { + // Arrange + val inputData = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to true, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 5555, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "192.168.1.100" + ) + every { mockParams.inputData } returns inputData + + val disabledStatus = AdbManager.AdbStatus( + enabled = false, + port = 0, + ip = null + ) + coEvery { AdbManager.getStatus(any()) } returns disabledStatus + + // Act + val result = worker.doWork() + + // Assert + assertTrue(result is ListenableWorker.Result.Success) + val outputData = (result as ListenableWorker.Result.Success).outputData + assertEquals(HealthCheckWorker.HEALTH_FAILED, outputData.getString(HealthCheckWorker.KEY_HEALTH_STATUS)) + assertEquals(false, outputData.getBoolean(HealthCheckWorker.KEY_CURRENT_ENABLED, true)) + } + + // ============================================================ + // Degradation detection tests + // ============================================================ + + @Test + fun `testDoWork_DetectsDegradation_WhenPortChanges - detects port change`() = runBlocking { + // Arrange + val inputData = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to true, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 5555, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "192.168.1.100" + ) + every { mockParams.inputData } returns inputData + + val changedStatus = AdbManager.AdbStatus( + enabled = true, + port = 5556, // Port changed! + ip = "192.168.1.100" + ) + coEvery { AdbManager.getStatus(any()) } returns changedStatus + + // Act + val result = worker.doWork() + + // Assert + assertTrue(result is ListenableWorker.Result.Success) + val outputData = (result as ListenableWorker.Result.Success).outputData + assertEquals(HealthCheckWorker.HEALTH_DEGRADED, outputData.getString(HealthCheckWorker.KEY_HEALTH_STATUS)) + assertEquals(true, outputData.getBoolean(HealthCheckWorker.KEY_DEGRADATION_DETECTED, false)) + } + + @Test + fun `testDoWork_DetectsDegradation_WhenIpChanges - detects IP address change`() = runBlocking { + // Arrange + val inputData = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to true, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 5555, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "192.168.1.100" + ) + every { mockParams.inputData } returns inputData + + val changedStatus = AdbManager.AdbStatus( + enabled = true, + port = 5555, + ip = "192.168.1.200" // IP changed! + ) + coEvery { AdbManager.getStatus(any()) } returns changedStatus + + // Act + val result = worker.doWork() + + // Assert + assertTrue(result is ListenableWorker.Result.Success) + val outputData = (result as ListenableWorker.Result.Success).outputData + assertEquals(HealthCheckWorker.HEALTH_DEGRADED, outputData.getString(HealthCheckWorker.KEY_HEALTH_STATUS)) + assertEquals(true, outputData.getBoolean(HealthCheckWorker.KEY_DEGRADATION_DETECTED, false)) + } + + @Test + fun `testDoWork_DetectsDegradation_WhenAdbDisabled - detects ADB disabled state`() = runBlocking { + // Arrange + val inputData = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to true, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 5555, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "192.168.1.100" + ) + every { mockParams.inputData } returns inputData + + val disabledStatus = AdbManager.AdbStatus( + enabled = false, // ADB disabled! + port = 0, + ip = null + ) + coEvery { AdbManager.getStatus(any()) } returns disabledStatus + + // Act + val result = worker.doWork() + + // Assert + assertTrue(result is ListenableWorker.Result.Success) + val outputData = (result as ListenableWorker.Result.Success).outputData + assertEquals(HealthCheckWorker.HEALTH_FAILED, outputData.getString(HealthCheckWorker.KEY_HEALTH_STATUS)) + // Note: degradation is detected when ADB was enabled but is now disabled + assertEquals(true, outputData.getBoolean(HealthCheckWorker.KEY_DEGRADATION_DETECTED, false)) + } + + // ============================================================ + // False positive prevention + // ============================================================ + + @Test + fun `testDoWork_NoFalsePositives_WhenNeverEnabled - no degradation when never enabled`() = runBlocking { + // Arrange + val inputData = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to false, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 0, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "" + ) + every { mockParams.inputData } returns inputData + + val disabledStatus = AdbManager.AdbStatus( + enabled = false, + port = 0, + ip = null + ) + coEvery { AdbManager.getStatus(any()) } returns disabledStatus + + // Act + val result = worker.doWork() + + // Assert + assertTrue(result is ListenableWorker.Result.Success) + val outputData = (result as ListenableWorker.Result.Success).outputData + assertEquals(HealthCheckWorker.HEALTH_FAILED, outputData.getString(HealthCheckWorker.KEY_HEALTH_STATUS)) + // No degradation because it was never enabled + assertEquals(false, outputData.getBoolean(HealthCheckWorker.KEY_DEGRADATION_DETECTED, true)) + } + + // ============================================================ + // Exception handling + // ============================================================ + + @Test + fun `testDoWork_HandlesException_ReturnsFailure - returns failure when exception occurs`() = runBlocking { + // Arrange + val inputData = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to true, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 5555, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "192.168.1.100" + ) + every { mockParams.inputData } returns inputData + + coEvery { AdbManager.getStatus(any()) } throws RuntimeException("Network error") + + // Act + val result = worker.doWork() + + // Assert + assertTrue(result is ListenableWorker.Result.Failure) + } + + // ============================================================ + // Degradation detection logic tests + // ============================================================ + + @Test + fun `testDetectDegradation_AllScenarios - comprehensive degradation scenarios`() = runBlocking { + // Test scenario 1: No degradation when everything matches + val inputHealthy = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to true, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 5555, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "192.168.1.100" + ) + every { mockParams.inputData } returns inputHealthy + val healthyStatus = AdbManager.AdbStatus(enabled = true, port = 5555, ip = "192.168.1.100") + coEvery { AdbManager.getStatus(any()) } returns healthyStatus + + var result = worker.doWork() + var outputData = (result as ListenableWorker.Result.Success).outputData + assertEquals(false, outputData.getBoolean(HealthCheckWorker.KEY_DEGRADATION_DETECTED, true)) + + // Test scenario 2: Degradation when IP is null (network lost) + val inputIpNull = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to true, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 5555, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "192.168.1.100" + ) + every { mockParams.inputData } returns inputIpNull + val statusIpNull = AdbManager.AdbStatus(enabled = true, port = 5555, ip = null) + coEvery { AdbManager.getStatus(any()) } returns statusIpNull + + result = worker.doWork() + outputData = (result as ListenableWorker.Result.Success).outputData + assertEquals(true, outputData.getBoolean(HealthCheckWorker.KEY_DEGRADATION_DETECTED, false)) + + // Test scenario 3: No degradation when initializing from never-enabled state + val inputNeverEnabled = workDataOf( + HealthCheckWorker.KEY_LAST_KNOWN_ENABLED to false, + HealthCheckWorker.KEY_LAST_KNOWN_PORT to 0, + HealthCheckWorker.KEY_LAST_KNOWN_IP to "" + ) + every { mockParams.inputData } returns inputNeverEnabled + val statusNewlyEnabled = AdbManager.AdbStatus(enabled = true, port = 5555, ip = "192.168.1.100") + coEvery { AdbManager.getStatus(any()) } returns statusNewlyEnabled + + result = worker.doWork() + outputData = (result as ListenableWorker.Result.Success).outputData + assertEquals(false, outputData.getBoolean(HealthCheckWorker.KEY_DEGRADATION_DETECTED, true)) + } +} diff --git a/context.json b/context.json new file mode 100644 index 0000000..bf2018d --- /dev/null +++ b/context.json @@ -0,0 +1,56 @@ +{ + "files_to_modify": { + "android-app": [ + "app/build.gradle.kts", + "app/src/main/AndroidManifest.xml", + "app/src/main/java/com/phenix/wirelessadb/PrefsManager.kt", + "app/src/main/java/com/phenix/wirelessadb/AdbService.kt", + "app/src/main/java/com/phenix/wirelessadb/viewmodel/AdbViewModel.kt", + "app/src/main/java/com/phenix/wirelessadb/ui/ControlFragment.kt" + ] + }, + "files_to_create": [ + "app/src/main/java/com/phenix/wirelessadb/health/HealthCheckWorker.kt", + "app/src/main/java/com/phenix/wirelessadb/health/ConnectionHealthManager.kt", + "app/src/main/res/drawable/ic_health.xml", + "app/src/main/res/drawable/ic_health_warning.xml" + ], + "files_to_reference": [ + "app/src/main/java/com/phenix/wirelessadb/AdbManager.kt", + "app/src/main/java/com/phenix/wirelessadb/AdbService.kt", + "app/src/main/java/com/phenix/wirelessadb/PrefsManager.kt", + "app/src/main/java/com/phenix/wirelessadb/viewmodel/AdbViewModel.kt", + "app/src/main/java/com/phenix/wirelessadb/ui/ControlFragment.kt" + ], + "patterns": { + "singleton_pattern": "Use 'object' keyword for singletons (e.g., AdbManager, PrefsManager)", + "preferences_pattern": "PrefsManager uses private const keys, getter/setter methods with context parameter", + "service_pattern": "Foreground service with companion object for start/stop, notification channel setup in onCreate", + "viewmodel_pattern": "AndroidViewModel with MutableLiveData private, exposed as LiveData, uses viewModelScope for coroutines", + "ui_pattern": "Fragment with ViewBinding, observers for LiveData in observeViewModel(), uses Toast for user feedback", + "async_pattern": "suspend functions in managers, viewModelScope.launch {} in ViewModels", + "notification_pattern": "NotificationCompat.Builder with channel ID, persistent notification for foreground service" + }, + "existing_implementations": { + "description": "ADB management system with status checking, foreground service, and UI updates", + "relevant_files": [ + "app/src/main/java/com/phenix/wirelessadb/AdbManager.kt", + "app/src/main/java/com/phenix/wirelessadb/AdbService.kt" + ], + "adb_status_check": "AdbManager.getStatus(context) returns AdbStatus with enabled, port, ip", + "preferences_storage": "PrefsManager singleton with SharedPreferences access", + "background_service": "AdbService is a foreground service with coroutineScope", + "missing_workmanager": "No WorkManager dependency or usage found - needs to be added" + }, + "dependencies": { + "current": [ + "androidx.core:core-ktx:1.12.0", + "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0", + "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0", + "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" + ], + "to_add": [ + "androidx.work:work-runtime-ktx:2.9.0" + ] + } +} diff --git a/project_index.json b/project_index.json new file mode 100644 index 0000000..373dff5 --- /dev/null +++ b/project_index.json @@ -0,0 +1,38 @@ +{ + "project_type": "single", + "services": { + "android-app": { + "path": ".", + "tech_stack": ["kotlin", "android", "coroutines", "viewmodel", "livedata"], + "sdk_version": 34, + "min_sdk": 26, + "build_command": "./gradlew assembleDebug", + "test_command": "./gradlew test", + "install_command": "./gradlew installDebug" + } + }, + "infrastructure": { + "docker": false, + "ci_cd": "github_actions", + "database": "shared_preferences" + }, + "conventions": { + "language": "kotlin", + "architecture": "mvvm", + "ui_pattern": "viewbinding", + "state_management": "livedata", + "async": "coroutines", + "di": "manual", + "preferences": "shared_preferences", + "background_jobs": "workmanager", + "naming": { + "managers": "ObjectManager (singleton object)", + "services": "ServiceName (extends Service)", + "viewmodels": "NameViewModel (extends AndroidViewModel)", + "fragments": "NameFragment (extends Fragment)", + "preferences": "PrefsManager singleton", + "icons": "ic_name.xml", + "layouts": "fragment_name.xml" + } + } +}