diff --git a/dashpilot-android/app/build.gradle.kts b/dashpilot-android/app/build.gradle.kts index c86f0788..da41521c 100644 --- a/dashpilot-android/app/build.gradle.kts +++ b/dashpilot-android/app/build.gradle.kts @@ -103,6 +103,7 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt index 003a84bc..be192bc1 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt @@ -34,6 +34,7 @@ import androidx.navigation.toRoute import androidx.startup.AppInitializer import app.rive.runtime.kotlin.RiveInitializer import com.softwiredtech.dashpilot.ble.FirmwareUpdateManager +import com.softwiredtech.dashpilot.ble.TeslaStatusSource import com.softwiredtech.dashpilot.datamodel.dash.DashboardType import com.softwiredtech.dashpilot.datamodel.dash.ManifestLoader import com.softwiredtech.dashpilot.datamodel.dash.availableDashboards @@ -48,6 +49,7 @@ import com.softwiredtech.dashpilot.navigation.DashboardRoute import com.softwiredtech.dashpilot.navigation.OnboardingRoute import com.softwiredtech.dashpilot.navigation.SettingsRoute import com.softwiredtech.dashpilot.navigation.SetupRoute +import com.softwiredtech.dashpilot.navigation.TeslaEnrollRoute import com.softwiredtech.dashpilot.navigation.ThemePickerRoute import com.softwiredtech.dashpilot.ui.AutomationsScreen import com.softwiredtech.dashpilot.ui.ControlScreen @@ -57,6 +59,7 @@ import com.softwiredtech.dashpilot.ui.LOCAL_ASSET_BASE_URL import com.softwiredtech.dashpilot.ui.SettingsScreen import com.softwiredtech.dashpilot.ui.ThemePickerScreen import com.softwiredtech.dashpilot.ui.onboarding.OnboardingScreen +import com.softwiredtech.dashpilot.ui.tesla.TeslaEnrollFlow import com.softwiredtech.dashpilot.ui.theme.DashPilotTheme import com.softwiredtech.dashpilot.util.NetworkUtil import com.softwiredtech.dashpilot.viewmodel.ConnectionViewModel @@ -174,6 +177,13 @@ class MainActivity : ComponentActivity() { onDispose { dashkitUpdateManager?.dispose() } } + // Phase 4: Tesla status/command channel rides the same bond. + val teslaSource = remember(bleManager) { bleManager?.let { TeslaStatusSource(it) } } + val teslaStatus = teslaSource?.status + val teslaResetPending = teslaSource?.resetPending + LaunchedEffect(teslaSource) { teslaSource?.start() } + DisposableEffect(teslaSource) { onDispose { teslaSource?.stop() } } + LaunchedEffect(startupTarget) { when (startupTarget.route) { ConnectionViewModel.StartupRoute.ONBOARDING_DASHKIT -> { @@ -247,6 +257,9 @@ class MainActivity : ComponentActivity() { bleManager = manager, dashState = dashStateFlow, pinnedControlId = pinnedControl, + teslaStatus = teslaStatus, + teslaResetPending = teslaResetPending, + onEnrollTesla = { navController.navigate(TeslaEnrollRoute) }, onConnect = { serverAddress, dataSourceType -> connectionVM.connect( context, serverAddress, dataSourceType, @@ -312,6 +325,10 @@ class MainActivity : ComponentActivity() { onDisplaySettingsChanged = { connectionVM.updateDisplaySettings(it) }, bleManager = manager, dashkitUpdateManager = dashkitUpdateManager, + teslaStatus = teslaStatus, + teslaResetPending = teslaResetPending, + onRemoveTesla = { teslaSource?.requestReset() == true }, + onEnrollTesla = { navController.navigate(TeslaEnrollRoute) }, onReplayOnboarding = { connectionVM.disconnect() setOnboardingCompleted(context, false) @@ -324,6 +341,15 @@ class MainActivity : ComponentActivity() { } ) } + composable { + val manager by connectionVM.bleManager.collectAsState() + TeslaEnrollFlow( + manager = manager, + statusFlow = teslaStatus, + vinState = connectionVM.vehicleVin, + onClose = { navController.popBackStack() }, + ) + } composable { ThemePickerScreen( onBack = { navController.popBackStack(ThemePickerRoute, inclusive = true) } diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt index 551e215d..81b0e1a7 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt @@ -11,6 +11,9 @@ object SetupRoute @Serializable object SettingsRoute +@Serializable +object TeslaEnrollRoute + @Serializable object ThemePickerRoute diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt index dbf788bd..e072042d 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -42,6 +43,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.softwiredtech.dashpilot.ble.TeslaStatus import com.softwiredtech.dashpilot.datamodel.dash.CarState import com.softwiredtech.dashpilot.datamodel.dash.DashState import com.softwiredtech.dashpilot.datasource.ConnectionStatus @@ -49,9 +51,12 @@ import com.softwiredtech.dashpilot.datasource.DashKitBleManager import com.softwiredtech.dashpilot.datasource.DataSourceType import com.softwiredtech.dashpilot.ui.controls.ControlActionButton import com.softwiredtech.dashpilot.ui.controls.controlById +import com.softwiredtech.dashpilot.ui.tesla.TeslaTile import com.softwiredtech.dashpilot.ui.theme.AccentColor import com.softwiredtech.dashpilot.ui.theme.DarkColors import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf import kotlin.math.roundToInt @@ -66,6 +71,9 @@ fun HomeScreen( bleManager: DashKitBleManager?, dashState: Flow?, pinnedControlId: String?, + teslaStatus: StateFlow?, + teslaResetPending: StateFlow?, + onEnrollTesla: () -> Unit, onConnect: (serverAddress: String, dataSourceType: String) -> Unit, onDisconnect: () -> Unit, onNext: () -> Unit, @@ -87,6 +95,9 @@ fun HomeScreen( bleManager = bleManager, connectionStatus = connectionStatus, pinnedControlId = pinnedControlId, + teslaStatus = teslaStatus, + teslaResetPending = teslaResetPending, + onEnrollTesla = onEnrollTesla, onConnect = onConnect, onDisconnect = onDisconnect, onSelectDataSource = { selectedDataSource = it }, @@ -115,6 +126,9 @@ private fun ConnectedHomeContent( bleManager: DashKitBleManager?, connectionStatus: ConnectionStatus, pinnedControlId: String?, + teslaStatus: StateFlow?, + teslaResetPending: StateFlow?, + onEnrollTesla: () -> Unit, onConnect: (serverAddress: String, dataSourceType: String) -> Unit, onDisconnect: () -> Unit, onSelectDataSource: (String) -> Unit, @@ -127,6 +141,10 @@ private fun ConnectedHomeContent( val state by (dashState ?: flowOf(fallback)).collectAsState(initial = fallback) val car = state.carState val useImperial = state.displaySettings.useImperial + val idleTesla = remember { MutableStateFlow(TeslaStatus.Idle) } + val tesla by (teslaStatus ?: idleTesla).collectAsState() + val idleReset = remember { MutableStateFlow(false) } + val resetPending by (teslaResetPending ?: idleReset).collectAsState() Column( modifier = Modifier @@ -199,6 +217,8 @@ private fun ConnectedHomeContent( Spacer(modifier = Modifier.height(32.dp)) + TeslaTile(status = tesla, resetPending = resetPending, onEnroll = onEnrollTesla) + controlById(pinnedControlId)?.let { action -> Text( text = "Pinned", diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt index f30db5f9..d13e450b 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SettingsScreen.kt @@ -66,7 +66,12 @@ import com.softwiredtech.dashpilot.datasource.ConnectionStatus import com.softwiredtech.dashpilot.datasource.DashKitBleManager import com.softwiredtech.dashpilot.ble.FirmwareUpdateManager import com.softwiredtech.dashpilot.ble.OtaState +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus import com.softwiredtech.dashpilot.ble.VehicleControl +import com.softwiredtech.dashpilot.ui.tesla.teslaTileSummary +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import com.softwiredtech.dashpilot.BuildConfig @@ -120,6 +125,10 @@ fun SettingsScreen( onDisplaySettingsChanged: (DisplaySettings) -> Unit = {}, bleManager: DashKitBleManager? = null, dashkitUpdateManager: FirmwareUpdateManager? = null, + teslaStatus: StateFlow? = null, + teslaResetPending: StateFlow? = null, + onRemoveTesla: () -> Boolean = { false }, + onEnrollTesla: () -> Unit = {}, onReplayOnboarding: () -> Unit = {}, onThemeClick: () -> Unit = {} ) { @@ -457,7 +466,14 @@ fun SettingsScreen( if (selectedTab.intValue == 1) { if (bleManager != null && dashkitUpdateManager != null) { - DashKitSettingsContent(bleManager, dashkitUpdateManager) + DashKitSettingsContent( + bleManager, + dashkitUpdateManager, + teslaStatus, + teslaResetPending, + onRemoveTesla, + onEnrollTesla, + ) } else { Text( text = stringResource(R.string.settings_dashkit_not_connected), @@ -540,6 +556,13 @@ private fun PairNewDeviceSection(bleManager: DashKitBleManager) { Text(text = stringResource(R.string.settings_pair_new_device), fontSize = 16.sp) } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.settings_pair_caption), + color = DarkColors.TextMuted, + fontSize = 12.sp + ) + if (!connected) { Spacer(modifier = Modifier.height(8.dp)) Text( @@ -581,7 +604,11 @@ private fun PairNewDeviceSection(bleManager: DashKitBleManager) { @Composable private fun DashKitSettingsContent( bleManager: DashKitBleManager, - updateManager: FirmwareUpdateManager + updateManager: FirmwareUpdateManager, + teslaStatus: StateFlow?, + teslaResetPending: StateFlow?, + onRemoveTesla: () -> Boolean, + onEnrollTesla: () -> Unit, ) { val connectionState by bleManager.connectionState.collectAsState() val connected = connectionState == ConnectionStatus.Connected @@ -603,6 +630,15 @@ private fun DashKitSettingsContent( PairNewDeviceSection(bleManager) Spacer(modifier = Modifier.height(24.dp)) + TeslaKeySection( + bleManager, + teslaStatus, + teslaResetPending, + onRemoveTesla, + onEnrollTesla, + ) + Spacer(modifier = Modifier.height(24.dp)) + DashKitMaintenanceSection(bleManager, connected) } @@ -688,6 +724,103 @@ private fun DashKitMaintenanceSection(bleManager: DashKitBleManager, connected: } } +@Composable +private fun TeslaKeySection( + bleManager: DashKitBleManager, + teslaStatus: StateFlow?, + teslaResetPending: StateFlow?, + onRemoveTesla: () -> Boolean, + onEnrollTesla: () -> Unit, +) { + val context = LocalContext.current + val connectionState by bleManager.connectionState.collectAsState() + val connected = connectionState == ConnectionStatus.Connected + val idleTesla = remember { MutableStateFlow(TeslaStatus.Idle) } + val status by (teslaStatus ?: idleTesla).collectAsState() + val idleReset = remember { MutableStateFlow(false) } + val resetPending by (teslaResetPending ?: idleReset).collectAsState() + val showResetDialog = remember { mutableStateOf(false) } + + SectionHeader(stringResource(R.string.settings_section_tesla_key)) + Text( + text = stringResource(R.string.settings_tesla_key_caption), + color = DarkColors.TextMuted, + fontSize = 12.sp, + ) + Spacer(modifier = Modifier.height(12.dp)) + val statusLabel = if (resetPending) { + stringResource(R.string.tesla_connection_removing) + } else { + teslaStatusText(status) + } + InfoRow(label = stringResource(R.string.settings_tesla_key_status), value = statusLabel) + Spacer(modifier = Modifier.height(12.dp)) + + val hasKey = status.linkState.hasKey + Button( + onClick = if (hasKey) ({ showResetDialog.value = true }) else onEnrollTesla, + enabled = connected && !resetPending, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = if (hasKey) DarkColors.SurfaceSelected else AccentColor, + contentColor = Color.White, + disabledContainerColor = DarkColors.Border, + disabledContentColor = DarkColors.TextMuted + ) + ) { + Text( + text = stringResource( + when { + resetPending -> R.string.tesla_connection_removing + hasKey -> R.string.settings_tesla_reset + else -> R.string.settings_tesla_enroll + } + ), + fontSize = 16.sp + ) + } + + if (showResetDialog.value) { + AlertDialog( + onDismissRequest = { showResetDialog.value = false }, + title = { Text(stringResource(R.string.settings_tesla_reset_dialog_title)) }, + text = { Text(stringResource(R.string.settings_tesla_reset_dialog_body)) }, + confirmButton = { + TextButton(onClick = { + showResetDialog.value = false + val ok = onRemoveTesla() + val msg = if (ok) { + context.getString(R.string.settings_tesla_reset_sent) + } else { + context.getString(R.string.settings_tesla_reset_failed) + } + android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_LONG).show() + }) { + Text(stringResource(R.string.settings_tesla_reset_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showResetDialog.value = false }) { + Text(stringResource(R.string.settings_tesla_reset_dialog_cancel)) + } + } + ) + } +} + +@Composable +private fun teslaStatusText(status: TeslaStatus): String = when (status.linkState) { + TeslaLinkState.NeverEnrolled -> stringResource(R.string.tesla_tile_key_not_set_up) + TeslaLinkState.Staged -> stringResource(R.string.tesla_tile_staged) + TeslaLinkState.Connecting -> stringResource(R.string.tesla_enroll_connecting_body) + TeslaLinkState.EnrolledNotConnected -> stringResource(R.string.tesla_tile_not_connected) + TeslaLinkState.EnrolledConnected -> teslaTileSummary(status) + .ifBlank { stringResource(R.string.tesla_tile_connected) } + TeslaLinkState.PairingWindow -> stringResource(R.string.tesla_status_pairing) + TeslaLinkState.EnrollmentFault -> stringResource(R.string.tesla_status_fault) + TeslaLinkState.Unknown -> "—" +} + @Composable private fun FirmwareUpdateSection(updateManager: FirmwareUpdateManager) { val scope = rememberCoroutineScope() diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/onboarding/DevicePuck.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/onboarding/DevicePuck.kt index e36f27f1..1626417f 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/onboarding/DevicePuck.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/onboarding/DevicePuck.kt @@ -39,7 +39,8 @@ enum class PairingState { Idle, Searching, Paired } @Composable fun DevicePuck( state: PairingState, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + accent: Color = OnboardingColors.Accent ) { val infinite = rememberInfiniteTransition(label = "puck") @@ -94,7 +95,7 @@ fun DevicePuck( if (state == PairingState.Searching) { ringAnims.forEach { (scaleAnim, alphaAnim) -> drawCircle( - color = OnboardingColors.Accent.copy(alpha = alphaAnim.value), + color = accent.copy(alpha = alphaAnim.value), radius = puckRadius * scaleAnim.value, center = center, style = Stroke(width = 1.dp.toPx()) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt new file mode 100644 index 00000000..3ed4bf7f --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollFlow.kt @@ -0,0 +1,397 @@ +package com.softwiredtech.dashpilot.ui.tesla + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.softwiredtech.dashpilot.R +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus +import com.softwiredtech.dashpilot.datasource.DashKitBleManager +import com.softwiredtech.dashpilot.ui.onboarding.DevicePuck +import com.softwiredtech.dashpilot.ui.onboarding.OnboardingPageScaffold +import com.softwiredtech.dashpilot.ui.onboarding.PairingState +import com.softwiredtech.dashpilot.ui.onboarding.PrimaryCta +import com.softwiredtech.dashpilot.ui.theme.OnboardingColors +import com.softwiredtech.dashpilot.ui.theme.TeslaCyan +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +private const val TAP_WINDOW_S = 60 + +@Composable +fun TeslaEnrollFlow( + manager: DashKitBleManager?, + statusFlow: StateFlow?, + vinState: StateFlow, + onClose: () -> Unit, +) { + val idleStatus = remember { MutableStateFlow(TeslaStatus.Idle) } + val status = statusFlow ?: idleStatus + val scope = rememberCoroutineScope() + val context = LocalContext.current + val controller = remember(manager, status, vinState) { + TeslaEnrollmentController.create(scope, context, manager, vinState, status) + } + LaunchedEffect(controller) { controller.begin() } + DisposableEffect(controller) { + onDispose { controller.stop() } + } + BackHandler(onBack = onClose) + + val state by controller.state.collectAsState() + + Box(modifier = Modifier.fillMaxSize().background(OnboardingColors.BgBase).systemBarsPadding()) { + TeslaEnrollmentContent( + state = state, + vinState = vinState, + onConnect = controller::connect, + onRetry = controller::retry, + onCancelPairing = controller::cancelPairingWindow, + onClose = onClose, + ) + } +} + +@Composable +fun TeslaEnrollmentContent( + state: TeslaEnrollmentState, + vinState: StateFlow, + onConnect: () -> Unit, + onRetry: () -> Unit, + onCancelPairing: () -> Unit, + onClose: () -> Unit, +) { + when (val s = state) { + TeslaEnrollmentState.CheckingFirmware -> ProgressStep( + subtitle = stringResource(R.string.tesla_enroll_checking), + onCancel = onClose, + ) + TeslaEnrollmentState.WaitingForVin -> ProgressStep( + subtitle = stringResource(R.string.tesla_enroll_reading_vin), + onCancel = onClose, + footer = { MaskedVinLine(vinState) }, + ) + is TeslaEnrollmentState.FindingVehicle -> ProgressStep( + subtitle = stringResource(R.string.tesla_enroll_finding), + onCancel = onClose, + footer = { MaskedTextLine(s.maskedVin) }, + ) + is TeslaEnrollmentState.Provisioning -> ProgressStep( + subtitle = stringResource(R.string.tesla_enroll_found), + onCancel = onClose, + footer = { MaskedTextLine(s.maskedVin) }, + ) + is TeslaEnrollmentState.ReadyToConnect -> ReadyStep( + maskedVin = s.maskedVin, + vinState = vinState, + onConnect = onConnect, + ) + is TeslaEnrollmentState.Connecting -> ConnectingStep( + maskedVin = s.maskedVin, + vinState = vinState, + onCancel = onCancelPairing, + ) + is TeslaEnrollmentState.WaitingForKeyCard -> TapCardStep( + carReady = s.carReady, + maskedVin = s.maskedVin, + vinState = vinState, + onCancel = onCancelPairing, + ) + TeslaEnrollmentState.Success -> SuccessStep( + vinState = vinState, + onDone = onClose, + ) + is TeslaEnrollmentState.Error -> ErrorStep( + reason = s.reason, + vinState = vinState, + onRetry = onRetry, + onCancel = onClose, + ) + } +} + +@Composable +private fun ProgressStep( + subtitle: String, + onCancel: () -> Unit, + footer: @Composable () -> Unit = {}, +) { + InlineScaffold( + title = stringResource(R.string.tesla_enroll_title), + subtitle = subtitle, + hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, + extra = { + Column(Modifier.fillMaxWidth()) { footer() } + }, + cta = { + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + }, + ) +} + +@Composable +private fun ReadyStep( + maskedVin: String?, + vinState: StateFlow, + onConnect: () -> Unit, +) { + InlineScaffold( + title = stringResource(R.string.tesla_enroll_title), + subtitle = stringResource(R.string.tesla_enroll_explain_body), + hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, + extra = { + Column(Modifier.fillMaxWidth()) { + if (maskedVin != null) MaskedTextLine(maskedVin) else MaskedVinLine(vinState) + RoleChip(stringResource(R.string.tesla_enroll_role_chip)) + } + }, + cta = { + Column(Modifier.fillMaxWidth()) { + PrimaryCta(label = stringResource(R.string.tesla_enroll_start), onClick = onConnect) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tesla_enroll_need_card), + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + ) + } + }, + ) +} + +@Composable +private fun ConnectingStep(maskedVin: String?, vinState: StateFlow, onCancel: () -> Unit) { + InlineScaffold( + title = stringResource(R.string.tesla_enroll_title), + subtitle = stringResource(R.string.tesla_enroll_connecting_body), + hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, + extra = { + Column(Modifier.fillMaxWidth()) { + if (maskedVin != null) MaskedTextLine(maskedVin) else MaskedVinLine(vinState) + RoleChip(stringResource(R.string.tesla_enroll_connecting_hint)) + } + }, + cta = { + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + }, + ) +} + +@Composable +private fun TapCardStep( + carReady: Boolean, + maskedVin: String?, + vinState: StateFlow, + onCancel: () -> Unit, +) { + var remaining by remember { mutableIntStateOf(TAP_WINDOW_S) } + LaunchedEffect(Unit) { + while (remaining > 0) { + delay(1000) + remaining-- + } + } + InlineScaffold( + title = stringResource(R.string.tesla_enroll_tap_title), + subtitle = stringResource(R.string.tesla_enroll_tap_body), + hero = { DevicePuck(state = PairingState.Searching, accent = TeslaCyan) }, + extra = { + Column(Modifier.fillMaxWidth()) { + if (maskedVin != null) MaskedTextLine(maskedVin) else MaskedVinLine(vinState) + if (carReady) { + Text( + text = stringResource(R.string.tesla_enroll_tap_ready, remaining), + color = TeslaCyan, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + } else { + Text( + text = stringResource(R.string.tesla_enroll_tap_hint, remaining), + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + ) + } + } + }, + cta = { + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + }, + ) +} + +@Composable +private fun SuccessStep(vinState: StateFlow, onDone: () -> Unit) { + InlineScaffold( + title = stringResource(R.string.tesla_enroll_success_title), + subtitle = stringResource(R.string.tesla_enroll_success_body), + hero = { DevicePuck(state = PairingState.Paired) }, + extra = { + Column(Modifier.fillMaxWidth()) { + MaskedVinLine(vinState) + RoleChip(stringResource(R.string.tesla_enroll_success_role)) + } + }, + cta = { PrimaryCta(label = stringResource(R.string.tesla_enroll_done), onClick = onDone) }, + ) +} + +@Composable +private fun ErrorStep( + reason: TeslaEnrollmentErrorReason, + vinState: StateFlow, + onRetry: () -> Unit, + onCancel: () -> Unit, +) { + val message = when (reason) { + TeslaEnrollmentErrorReason.FirmwareUnsupported -> + stringResource(R.string.tesla_enroll_firmware_update_needed) + TeslaEnrollmentErrorReason.VinUnavailable -> + stringResource(R.string.tesla_enroll_vin_timeout) + TeslaEnrollmentErrorReason.VehicleNotFound -> + stringResource(R.string.tesla_enroll_scan_timeout) + TeslaEnrollmentErrorReason.NotAcknowledged -> + stringResource(R.string.tesla_enroll_provision_failed) + TeslaEnrollmentErrorReason.TapTimeout -> stringResource(R.string.tesla_fault_tap_timeout) + TeslaEnrollmentErrorReason.Rejected -> stringResource(R.string.tesla_fault_rejected) + TeslaEnrollmentErrorReason.Protocol -> stringResource(R.string.tesla_fault_protocol) + TeslaEnrollmentErrorReason.Persist -> stringResource(R.string.tesla_fault_persist) + TeslaEnrollmentErrorReason.Generic -> stringResource(R.string.tesla_fault_generic) + } + InlineScaffold( + title = stringResource(R.string.tesla_enroll_error_title), + subtitle = message, + hero = { DevicePuck(state = PairingState.Idle, accent = OnboardingColors.LedDim) }, + extra = { MaskedVinLine(vinState) }, + cta = { + Column(Modifier.fillMaxWidth()) { + PrimaryCta(label = stringResource(R.string.tesla_enroll_retry), onClick = onRetry) + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.tesla_enroll_cancel), color = OnboardingColors.TextSecondary) + } + } + }, + ) +} + +@Composable +private fun MaskedVinLine(vinState: StateFlow) { + val vin by vinState.collectAsState() + MaskedTextLine(maskVinTail(vin)) +} + +@Composable +private fun MaskedTextLine(maskedTail: String?) { + if (maskedTail == null) return + Text( + text = stringResource(R.string.tesla_enroll_masked_vin, maskedTail), + color = OnboardingColors.TextMuted, + fontSize = 13.sp, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) +} + +@Composable +private fun InlineScaffold( + title: String, + subtitle: String, + cta: @Composable () -> Unit, + hero: @Composable () -> Unit, + extra: @Composable () -> Unit = {}, +) { + OnboardingPageScaffold( + title = title, + subtitle = subtitle, + cta = cta, + hero = { + Box(contentAlignment = Alignment.Center) { + hero() + } + }, + extra = extra, + ) +} + +@Composable +private fun RoleChip(text: String) { + Box( + modifier = Modifier + .background(OnboardingColors.Surface, MaterialTheme.shapes.small) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = text, + color = OnboardingColors.TextSecondary, + fontSize = 13.sp, + ) + } +} + +/** Builds the one-line Home-tile summary, e.g. "Present · Unlocked · Awake". Always + * shows the presence/lock/sleep state so the driver can see live values — including + * the "negative" states (not present / unlocked / awake); unknown (0xFF) is omitted. */ +@Composable +fun teslaTileSummary(status: TeslaStatus): String { + if (status.linkState != TeslaLinkState.EnrolledConnected && + status.linkState != TeslaLinkState.EnrolledNotConnected + ) { + return "" + } + val parts = buildList { + add( + when (status.presence) { + 1 -> stringResource(R.string.tesla_status_present) + 0 -> stringResource(R.string.tesla_status_not_present) + else -> null + } + ) + add( + when (status.lock) { + 1 -> stringResource(R.string.tesla_status_locked) + 0 -> stringResource(R.string.tesla_status_unlocked) + else -> null + } + ) + add( + when (status.sleep) { + 1 -> stringResource(R.string.tesla_status_asleep) + 0 -> stringResource(R.string.tesla_status_awake) + else -> null + } + ) + }.filterNotNull() + return parts.joinToString(" · ") +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollmentController.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollmentController.kt new file mode 100644 index 00000000..19af45e9 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollmentController.kt @@ -0,0 +1,326 @@ +package com.softwiredtech.dashpilot.ui.tesla + +import android.bluetooth.BluetoothManager +import android.content.Context +import com.softwiredtech.dashpilot.ble.TeslaClient +import com.softwiredtech.dashpilot.ble.TeslaFaultDetail +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus +import com.softwiredtech.dashpilot.ble.TeslaVehicleScanner +import com.softwiredtech.dashpilot.datasource.DashKitBleManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +internal fun maskVinTail(vin: String?): String? = + vin?.takeLast(4)?.let { "••••$it" } + +fun interface TeslaMatchScanner { + fun scanMatches(vin: String): Flow +} + +enum class TeslaEnrollmentErrorReason { + FirmwareUnsupported, + VinUnavailable, + VehicleNotFound, + NotAcknowledged, + TapTimeout, + Rejected, + Protocol, + Persist, + Generic, +} + +internal fun faultReason(detail: TeslaFaultDetail): TeslaEnrollmentErrorReason = when (detail) { + TeslaFaultDetail.TapTimeout -> TeslaEnrollmentErrorReason.TapTimeout + TeslaFaultDetail.Rejected -> TeslaEnrollmentErrorReason.Rejected + TeslaFaultDetail.Protocol -> TeslaEnrollmentErrorReason.Protocol + TeslaFaultDetail.Persist -> TeslaEnrollmentErrorReason.Persist + TeslaFaultDetail.None -> TeslaEnrollmentErrorReason.Generic +} + +sealed interface TeslaEnrollmentState { + data object CheckingFirmware : TeslaEnrollmentState + data object WaitingForVin : TeslaEnrollmentState + data class FindingVehicle(val maskedVin: String?) : TeslaEnrollmentState + data class Provisioning(val maskedVin: String?) : TeslaEnrollmentState + + data class ReadyToConnect(val maskedVin: String?) : TeslaEnrollmentState + data class Connecting(val maskedVin: String?) : TeslaEnrollmentState + data class WaitingForKeyCard(val carReady: Boolean, val maskedVin: String?) : TeslaEnrollmentState + data object Success : TeslaEnrollmentState + data class Error(val reason: TeslaEnrollmentErrorReason, val maskedVin: String?) : TeslaEnrollmentState +} + +class TeslaEnrollmentController( + private val scope: CoroutineScope, + private val vinState: StateFlow, + private val status: StateFlow, + private val matchScanner: TeslaMatchScanner, + private val hasTeslaService: () -> Boolean, + private val provision: (vin: String, mac: String) -> Boolean, + private val startEnrollment: () -> Boolean, + private val cancelPairing: () -> Unit, +) { + + companion object { + const val VIN_TIMEOUT_MS = 20_000L + const val SCAN_TIMEOUT_MS = 20_000L + const val PROVISION_ACK_TIMEOUT_MS = 8_000L + const val CAPABILITY_TIMEOUT_MS = 6_000L + const val CAPABILITY_POLL_MS = 250L + const val PROVISION_DISPATCH_RETRIES = 3 + const val PROVISION_RETRY_DELAY_MS = 750L + fun create( + scope: CoroutineScope, + context: Context, + manager: DashKitBleManager?, + vinState: StateFlow, + status: StateFlow, + ): TeslaEnrollmentController { + @Suppress("MissingPermission") // BLUETOOTH_SCAN requested with the other BLE permissions + val adapter = runCatching { + (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + }.getOrNull() + val scanner = TeslaVehicleScanner(adapter) + return TeslaEnrollmentController( + scope = scope, + vinState = vinState, + status = status, + matchScanner = TeslaMatchScanner { vin -> + scanner.scan(vin).map { it.address } + }, + hasTeslaService = { + manager?.gatt?.getService(TeslaClient.SERVICE_UUID) + ?.getCharacteristic(TeslaClient.COMMAND_CHAR_UUID) != null + }, + provision = { vin, mac -> + manager?.let { TeslaClient.sendProvision(it, vin, mac) } ?: false + }, + startEnrollment = { manager?.let { TeslaClient.sendStart(it) } ?: false }, + cancelPairing = { manager?.let { TeslaClient.sendCancel(it) } }, + ) + } + } + + private val _state = MutableStateFlow(TeslaEnrollmentState.CheckingFirmware) + val state: StateFlow = _state.asStateFlow() + + private fun set(next: TeslaEnrollmentState) { + _state.value = next + } + + private var pipeline: Job? = null + private var observer: Job? = null + + private fun vin(): String? = vinState.value + + fun begin() { + if (observer?.isActive == true) return + observer = scope.launch { + status.collect { onStatus(it) } + } + runPipeline() + } + + fun stop() { + observer?.cancel() + observer = null + pipeline?.cancel() + pipeline = null + } + + fun retry() { + val current = _state.value + if (current !is TeslaEnrollmentState.Error) return + when (current.reason) { + TeslaEnrollmentErrorReason.TapTimeout, + TeslaEnrollmentErrorReason.Rejected, + TeslaEnrollmentErrorReason.Protocol, + TeslaEnrollmentErrorReason.Persist, + TeslaEnrollmentErrorReason.Generic, + -> { + if (startEnrollment()) { + set(TeslaEnrollmentState.Connecting(current.maskedVin)) + } else { + set(error(TeslaEnrollmentErrorReason.NotAcknowledged)) + } + } + TeslaEnrollmentErrorReason.FirmwareUnsupported, + TeslaEnrollmentErrorReason.VinUnavailable, + TeslaEnrollmentErrorReason.VehicleNotFound, + TeslaEnrollmentErrorReason.NotAcknowledged, + -> runPipeline() + } + } + + fun connect() { + val current = _state.value + if (current !is TeslaEnrollmentState.ReadyToConnect) return + if (startEnrollment()) { + set(TeslaEnrollmentState.Connecting(current.maskedVin)) + } else { + set(error(TeslaEnrollmentErrorReason.NotAcknowledged)) + } + } + + fun cancelPairingWindow() = cancelPairing() + + private fun terminalStateFor(status: TeslaStatus): TeslaEnrollmentState? = + when (status.linkState) { + TeslaLinkState.Staged -> + TeslaEnrollmentState.ReadyToConnect(maskVinTail(vin())) + TeslaLinkState.Connecting -> + TeslaEnrollmentState.Connecting(maskVinTail(vin())) + TeslaLinkState.PairingWindow -> + waitingForKeyCard(status) + TeslaLinkState.EnrolledNotConnected, TeslaLinkState.EnrolledConnected -> + TeslaEnrollmentState.Success + TeslaLinkState.EnrollmentFault -> + error(faultReason(status.faultDetail)) + TeslaLinkState.NeverEnrolled, TeslaLinkState.Unknown -> null + } + + private fun runPipeline() { + pipeline?.cancel() + pipeline = scope.launch { + set(TeslaEnrollmentState.CheckingFirmware) + + // Service discovery may still be finishing when this screen opens. + val serviceReady = withTimeoutOrNull(CAPABILITY_TIMEOUT_MS) { + while (!hasTeslaService()) delay(CAPABILITY_POLL_MS) + true + } ?: false + if (!serviceReady) { + set(error(TeslaEnrollmentErrorReason.FirmwareUnsupported)) + return@launch + } + + terminalStateFor(status.value)?.let { route -> + set(route) + return@launch + } + + set(TeslaEnrollmentState.WaitingForVin) + val vin = withTimeoutOrNull(VIN_TIMEOUT_MS) { + vinState.first { !it.isNullOrEmpty() } + } + + terminalStateFor(status.value)?.let { route -> + set(route) + return@launch + } + if (vin == null) { + set(error(TeslaEnrollmentErrorReason.VinUnavailable)) + return@launch + } + + set(TeslaEnrollmentState.FindingVehicle(maskVinTail(vin))) + val mac = try { + withTimeoutOrNull(SCAN_TIMEOUT_MS) { + matchScanner.scanMatches(vin).firstOrNull() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + terminalStateFor(status.value)?.let { route -> + set(route) + return@launch + } + if (mac == null) { + set(error(TeslaEnrollmentErrorReason.VehicleNotFound)) + return@launch + } + + set(TeslaEnrollmentState.Provisioning(maskVinTail(vin))) + var dispatched = false + for (attempt in 0 until PROVISION_DISPATCH_RETRIES) { + val ok = runCatching { provision(vin, mac) }.getOrDefault(false) + if (ok) { + dispatched = true + break + } + if (attempt < PROVISION_DISPATCH_RETRIES - 1) delay(PROVISION_RETRY_DELAY_MS) + } + if (!dispatched) { + set(error(TeslaEnrollmentErrorReason.NotAcknowledged)) + return@launch + } + + val verdict = withTimeoutOrNull(PROVISION_ACK_TIMEOUT_MS) { + status.first { + it.linkState == TeslaLinkState.Staged || + it.linkState == TeslaLinkState.EnrollmentFault || + it.linkState == TeslaLinkState.EnrolledNotConnected || + it.linkState == TeslaLinkState.EnrolledConnected + }.linkState + } + set( + when (verdict) { + TeslaLinkState.Staged -> TeslaEnrollmentState.ReadyToConnect(maskVinTail(vin)) + TeslaLinkState.EnrollmentFault -> + error(faultReason(status.value.faultDetail)) + TeslaLinkState.EnrolledNotConnected, TeslaLinkState.EnrolledConnected -> + TeslaEnrollmentState.Success + else -> error(TeslaEnrollmentErrorReason.NotAcknowledged) + } + ) + } + } + + private fun onStatus(s: TeslaStatus) { + val current = _state.value + when (s.linkState) { + TeslaLinkState.NeverEnrolled, TeslaLinkState.Unknown -> Unit + + TeslaLinkState.Staged -> + if (current !is TeslaEnrollmentState.Provisioning && + current !is TeslaEnrollmentState.ReadyToConnect + ) { + pipeline?.cancel() + set(TeslaEnrollmentState.ReadyToConnect(maskVinTail(vin()))) + } + + TeslaLinkState.Connecting, TeslaLinkState.PairingWindow -> { + if (current !is TeslaEnrollmentState.Provisioning && + current !is TeslaEnrollmentState.CheckingFirmware && + current !is TeslaEnrollmentState.WaitingForVin && + current !is TeslaEnrollmentState.FindingVehicle + ) { + set(terminalStateFor(s)!!) + } + } + + TeslaLinkState.EnrolledNotConnected, TeslaLinkState.EnrolledConnected -> { + pipeline?.cancel() + set(TeslaEnrollmentState.Success) + } + TeslaLinkState.EnrollmentFault -> { + pipeline?.cancel() + set(error(faultReason(s.faultDetail))) + } + } + } + + private fun waitingForKeyCard(s: TeslaStatus) = TeslaEnrollmentState.WaitingForKeyCard( + carReady = s.flags and 0x01 != 0, + maskedVin = maskVinTail(vin()), + ) + + private fun error(reason: TeslaEnrollmentErrorReason) = + TeslaEnrollmentState.Error(reason, maskVinTail(vin())) +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt new file mode 100644 index 00000000..b456adce --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTile.kt @@ -0,0 +1,105 @@ +package com.softwiredtech.dashpilot.ui.tesla + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.softwiredtech.dashpilot.R +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus +import com.softwiredtech.dashpilot.ui.theme.DarkColors +import com.softwiredtech.dashpilot.ui.theme.OnboardingColors +import com.softwiredtech.dashpilot.ui.theme.TeslaCyan + +internal fun teslaTileTextRes(status: TeslaStatus, resetPending: Boolean): Int? = when { + resetPending -> R.string.tesla_connection_removing + status.linkState == TeslaLinkState.NeverEnrolled -> R.string.tesla_tile_connect + status.linkState == TeslaLinkState.Staged -> R.string.tesla_tile_staged + status.linkState == TeslaLinkState.Connecting -> R.string.tesla_enroll_connecting_body + status.linkState == TeslaLinkState.EnrolledNotConnected -> R.string.tesla_tile_not_connected + status.linkState == TeslaLinkState.EnrolledConnected -> null + status.linkState == TeslaLinkState.PairingWindow -> R.string.tesla_enroll_tap_title + status.linkState == TeslaLinkState.EnrollmentFault -> R.string.tesla_tile_fault + else -> R.string.tesla_tile_not_connected +} + +@Composable +fun TeslaTile(status: TeslaStatus, resetPending: Boolean, onEnroll: () -> Unit) { + val tapEnabled = !resetPending && ( + status.linkState == TeslaLinkState.Staged || + status.linkState == TeslaLinkState.NeverEnrolled || + status.linkState == TeslaLinkState.EnrollmentFault + ) + + val textRes = teslaTileTextRes(status, resetPending) + val text = textRes?.let { stringResource(it) } ?: teslaTileSummary(status) + .ifBlank { stringResource(R.string.tesla_tile_connected) } + + val ledColor: Color = when { + resetPending -> DarkColors.Disabled + status.linkState == TeslaLinkState.EnrolledConnected -> OnboardingColors.Accent + status.linkState == TeslaLinkState.NeverEnrolled -> TeslaCyan + status.linkState == TeslaLinkState.Staged -> TeslaCyan + status.linkState == TeslaLinkState.PairingWindow -> TeslaCyan + else -> DarkColors.Disabled + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(DarkColors.Surface) + .then(if (tapEnabled) Modifier.clickable(onClick = onEnroll) else Modifier) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(ledColor), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.tesla_label), + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.width(12.dp)) + Text( + text = text, + color = DarkColors.TextMuted, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + if (tapEnabled) { + Spacer(Modifier.width(16.dp)) + Text(text = "›", color = DarkColors.TextMuted, fontSize = 20.sp) + } + } + Spacer(Modifier.height(12.dp)) +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/theme/Color.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/theme/Color.kt index 875964e8..2d3948aa 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/theme/Color.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/theme/Color.kt @@ -12,6 +12,10 @@ val Pink40 = Color(0xFF7D5260) val AccentColor = Color(0xFF5CBD68) +// Phase 4 Tesla pair/status cyan (pairing window + staged tile). The in-app motif +// for a state the physical DashKit LED (buried in the car trim) can't show. +val TeslaCyan = Color(0xFF4FD1FF) + object DarkColors { val Background = Color(0xFF0D0D0D) val Surface = Color(0xFF1A1A1A) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt index 787e2920..61cd6257 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt @@ -68,8 +68,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull @@ -278,6 +281,10 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { private val _dashState = MutableStateFlow?>(null) val dashState = _dashState.asStateFlow() + // Vehicle VIN from CarState (assembled by the native mapper); null until known. + private val _vehicleVin = MutableStateFlow(null) + val vehicleVin: StateFlow = _vehicleVin.asStateFlow() + private fun phoneBatteryFlow(context: Context): Flow = flow { val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager while (true) { @@ -361,6 +368,14 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { _dataSource.value = ds + launch { + ds.incomingMessages + .map { it.vin } + .filter { it.isNotEmpty() } + .distinctUntilChanged() + .collect { if (_dataSource.value === ds) _vehicleVin.value = it } + } + _displaySettings.value = DisplaySettings( showPhoneBattery = prefs.getBoolean(PREF_SHOW_PHONE_BATTERY, DEFAULT_SHOW_PHONE_BATTERY), showCarBattery = prefs.getBoolean(PREF_SHOW_CAR_BATTERY, DEFAULT_SHOW_CAR_BATTERY), @@ -442,6 +457,7 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { _bleManager.value?.disconnect() _bleManager.value = null _dashState.value = null + _vehicleVin.value = null _hasAutoNavigatedToDashboard.value = false } diff --git a/dashpilot-android/app/src/main/res/values/strings.xml b/dashpilot-android/app/src/main/res/values/strings.xml index 71fd1f50..e86e7759 100644 --- a/dashpilot-android/app/src/main/res/values/strings.xml +++ b/dashpilot-android/app/src/main/res/values/strings.xml @@ -112,4 +112,71 @@ Check for firmware updates Replay onboarding + + + Tesla + + Connect your Tesla + Not connected + Car found — Connect + Car not connected + Connection failed — tap to retry + Connected + Present + Not present + Locked + Unlocked + Asleep + Awake + Ready to tap card + Connection failed + + + Connect your Tesla + DashKit adds itself to your Tesla as a Charging Manager — it can read status and control charging, but can\'t unlock or drive the car. + Charging Manager · Read + charge + Connect + Have your key card ready. + Contacting your car… DashKit is reaching out to pair. + Keep your phone and DashKit nearby. + Checking DashKit… + Reading vehicle identity… + Finding your Tesla… + Tesla · VIN %1$s + Car found — connecting to DashKit… + DashKit didn\'t confirm the car. Check the connection and try again. + Update DashKit firmware to connect your Tesla. + Couldn\'t read the VIN. Wake the vehicle and try again. If this continues, update DashKit firmware. + Make sure Bluetooth is on and stay near the vehicle. + Tap your key card + Place your key card on the center console, then approve on the car\'s touchscreen. + %1$d s left + Car ready — tap your key card now (%1$d s) + Connected + DashKit is now a Charging Manager on your Tesla. + Read + charge enabled + Done + Connection failed + Try again + Cancel + You didn\'t tap the card in time. Try again. + The car rejected the connection. Check you\'re near the right car and using its key card. + DashKit couldn\'t talk to the car. Try again. + DashKit couldn\'t save the connection. Try again. + Something went wrong. Try again. + + + Tesla Connection + Read status and control charging via BLE. + Status + Connect Tesla + Remove Tesla connection + Remove Tesla connection? + Remove DashKit from your car\'s Locks screen, then remove the connection here. You\'ll need your key card to reconnect. + Remove + Cancel + Removing Tesla connection… + Couldn\'t remove the connection. Try again. + Removing connection… + Pairs another phone — separate from your Tesla connection. diff --git a/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollmentControllerTest.kt b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollmentControllerTest.kt new file mode 100644 index 00000000..6fe55c85 --- /dev/null +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ui/tesla/TeslaEnrollmentControllerTest.kt @@ -0,0 +1,103 @@ +package com.softwiredtech.dashpilot.ui.tesla + +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TeslaEnrollmentControllerTest { + private val vin = "5YJ3E7EB1MF123456" + private val mac = "AA:BB:CC:DD:EE:FF" + + @Test + fun stages_vehicle_before_explicit_connect() = runTest { + val vinState = MutableStateFlow(vin) + val status = MutableStateFlow(TeslaStatus.Idle) + val provisions = mutableListOf>() + var starts = 0 + val controller = controller( + vinState = vinState, + status = status, + provision = { stagedVin, stagedMac -> + provisions += stagedVin to stagedMac + status.value = status.value.copy(linkState = TeslaLinkState.Staged) + true + }, + startEnrollment = { + starts++ + true + }, + ).also { it.begin() } + + advanceUntilIdle() + assertTrue(controller.state.value is TeslaEnrollmentState.ReadyToConnect) + assertEquals(listOf(vin to mac), provisions) + assertEquals(0, starts) + + controller.connect() + assertEquals(1, starts) + assertTrue(controller.state.value is TeslaEnrollmentState.Connecting) + } + + @Test + fun gatt_dispatch_is_not_staging_acknowledgement() = runTest { + val vinState = MutableStateFlow(vin) + val status = MutableStateFlow(TeslaStatus.Idle) + val controller = controller( + vinState = vinState, + status = status, + provision = { _, _ -> true }, + ).also { it.begin() } + + advanceUntilIdle() + + val error = controller.state.value as TeslaEnrollmentState.Error + assertEquals(TeslaEnrollmentErrorReason.NotAcknowledged, error.reason) + } + + @Test + fun missing_vin_never_provisions() = runTest { + val vinState = MutableStateFlow(null) + val status = MutableStateFlow(TeslaStatus.Idle) + var provisions = 0 + val controller = controller( + vinState = vinState, + status = status, + provision = { _, _ -> + provisions++ + true + }, + ).also { it.begin() } + + advanceUntilIdle() + + assertEquals(0, provisions) + val error = controller.state.value as TeslaEnrollmentState.Error + assertEquals(TeslaEnrollmentErrorReason.VinUnavailable, error.reason) + } + + private fun kotlinx.coroutines.test.TestScope.controller( + vinState: MutableStateFlow, + status: MutableStateFlow, + provision: (String, String) -> Boolean, + startEnrollment: () -> Boolean = { true }, + ) = TeslaEnrollmentController( + scope = CoroutineScope(StandardTestDispatcher(testScheduler)), + vinState = vinState, + status = status, + matchScanner = { flowOf(mac) }, + hasTeslaService = { true }, + provision = provision, + startEnrollment = startEnrollment, + cancelPairing = {}, + ) +} diff --git a/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTileTest.kt b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTileTest.kt new file mode 100644 index 00000000..c823d414 --- /dev/null +++ b/dashpilot-android/app/src/test/java/com/softwiredtech/dashpilot/ui/tesla/TeslaTileTest.kt @@ -0,0 +1,16 @@ +package com.softwiredtech.dashpilot.ui.tesla + +import com.softwiredtech.dashpilot.R +import com.softwiredtech.dashpilot.ble.TeslaLinkState +import com.softwiredtech.dashpilot.ble.TeslaStatus +import org.junit.Assert.assertEquals +import org.junit.Test + +class TeslaTileTest { + @Test + fun pending_removal_hides_connected_status() { + val status = TeslaStatus.Idle.copy(linkState = TeslaLinkState.EnrolledConnected) + + assertEquals(R.string.tesla_connection_removing, teslaTileTextRes(status, true)) + } +} diff --git a/dashpilot-android/gradle/libs.versions.toml b/dashpilot-android/gradle/libs.versions.toml index 4dc6feef..bbbcefbd 100644 --- a/dashpilot-android/gradle/libs.versions.toml +++ b/dashpilot-android/gradle/libs.versions.toml @@ -11,6 +11,7 @@ composeBom = "2025.03.00" kotlinxSerializationJson = "1.7.3" navigationCompose = "2.9.0" fragment = "1.8.6" +kotlinxCoroutinesTest = "1.10.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,6 +31,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-fragment = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }