feat(aiturk): add the connect-to-computer screen (QR scan, code, deep link)
Landing now leads with QR / 8-character code pairing; manual login stays as the secondary action. aiturk://pair links open the same screen and ask for confirmation first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
1 parent
c2b08ff73e
commit
6f4309c746
11 files changed
+721
No files matched your search
@@ -24,6 +24,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLink
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||
@@ -62,6 +63,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
consumeNotificationIntent(intent)
|
||||
AiturkPairDeepLink.offer(intent)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
@@ -129,6 +131,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
consumeNotificationIntent(intent)
|
||||
AiturkPairDeepLink.offer(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -52,6 +52,7 @@ import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLinkEffect
|
||||
import com.m57.hermescontrol.ui.common.DisableDrawerGestures
|
||||
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
||||
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
||||
@@ -66,6 +67,7 @@ import com.m57.hermescontrol.ui.settings.SettingsVaultPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
||||
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||
import kotlinx.coroutines.launch
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairScreen as AiturkPairScreenContent
|
||||
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
||||
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
|
||||
|
||||
@@ -79,6 +81,9 @@ private fun appEntryProvider(
|
||||
onAuthLogin = {
|
||||
NavigationController.navigateTo(AuthLoginScreen)
|
||||
},
|
||||
onAiturkPair = {
|
||||
NavigationController.navigateTo(AiturkPairScreen)
|
||||
},
|
||||
)
|
||||
// Landing doesn't use HermesScaffold — opt out of drawer gestures explicitly (issue #619).
|
||||
DisableDrawerGestures()
|
||||
@@ -96,6 +101,18 @@ private fun appEntryProvider(
|
||||
DisableDrawerGestures()
|
||||
}
|
||||
|
||||
entry<AiturkPairScreen> {
|
||||
AiturkPairScreenContent(
|
||||
onConnected = {
|
||||
NavigationController.resetTo(ChatScreen)
|
||||
},
|
||||
onBack = {
|
||||
NavigationController.goBack()
|
||||
},
|
||||
)
|
||||
DisableDrawerGestures()
|
||||
}
|
||||
|
||||
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
||||
addEntryProvider(clazz = screen.key::class) {
|
||||
screen.content(sessionId, openDrawer)
|
||||
@@ -193,6 +210,7 @@ fun MainNavigation(sessionId: String? = null) {
|
||||
|
||||
val backStack = remember(startScreen) { NavBackStack(startScreen) }
|
||||
NavigationController.backStack = backStack
|
||||
AiturkPairDeepLinkEffect()
|
||||
|
||||
val currentScreen = backStack.lastOrNull() ?: startScreen
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
|
||||
@@ -7,6 +7,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object AuthLoginScreen : NavKey
|
||||
|
||||
@Serializable data object AiturkPairScreen : NavKey
|
||||
|
||||
@Serializable data object ChatScreen : NavKey
|
||||
|
||||
@Serializable data object SettingsScreen : NavKey
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.m57.hermescontrol.ui.aiturkpair
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.m57.hermescontrol.AiturkPairScreen
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Hands an `aiturk://pair?...` VIEW intent to the pairing screen. The link is
|
||||
* only parked here; the screen asks the user to confirm before connecting,
|
||||
* because any app or web page can fire this intent.
|
||||
*/
|
||||
object AiturkPairDeepLink {
|
||||
private val _pending = MutableStateFlow<String?>(null)
|
||||
val pending: StateFlow<String?> = _pending.asStateFlow()
|
||||
|
||||
/** @return true when [intent] was an AITURK pairing link. */
|
||||
fun offer(intent: Intent?): Boolean {
|
||||
if (intent?.action != Intent.ACTION_VIEW) return false
|
||||
val raw = intent.dataString
|
||||
if (!AiturkPairing.looksLikePairingLink(raw)) return false
|
||||
// Consume it so a recreated activity does not replay the same link.
|
||||
intent.data = null
|
||||
_pending.value = raw
|
||||
return true
|
||||
}
|
||||
|
||||
fun take(): String? = _pending.value.also { _pending.value = null }
|
||||
}
|
||||
|
||||
/** Opens the pairing screen whenever a deep link is waiting. */
|
||||
@Composable
|
||||
fun AiturkPairDeepLinkEffect() {
|
||||
val pending by AiturkPairDeepLink.pending.collectAsState()
|
||||
LaunchedEffect(pending) {
|
||||
if (pending != null) NavigationController.navigateTo(AiturkPairScreen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package com.m57.hermescontrol.ui.aiturkpair
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Computer
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
|
||||
|
||||
/** Any RFC1918 address: pairing targets are always private IPv4, so the answer is the same. */
|
||||
private const val LAN_PROBE_URL = "https://192.168.0.1/"
|
||||
private const val CODE_GROUP = 4
|
||||
|
||||
@Composable
|
||||
fun AiturkPairScreen(
|
||||
onConnected: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AiturkPairViewModel = viewModel { AiturkPairViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
val scanPrompt = stringResource(R.string.aiturk_pair_scan_prompt)
|
||||
val scanLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
viewModel.onQrScanned(result.contents, lanPermissionGranted(context))
|
||||
}
|
||||
val launchScanner = {
|
||||
scanLauncher.launch(
|
||||
ScanOptions()
|
||||
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
.setCaptureActivity(AiturkQrCaptureActivity::class.java)
|
||||
.setPrompt(scanPrompt)
|
||||
.setBeepEnabled(false)
|
||||
.setBarcodeImageEnabled(false)
|
||||
.setOrientationLocked(true),
|
||||
)
|
||||
}
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) launchScanner() else viewModel.onCameraPermissionDenied()
|
||||
}
|
||||
val lanPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
viewModel.onLanPermissionResult(granted)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.lanPermissionNeeded) {
|
||||
if (state.lanPermissionNeeded) lanPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
}
|
||||
LaunchedEffect(state.connectionSuccess) {
|
||||
if (state.connectionSuccess) onConnected()
|
||||
}
|
||||
val deepLink by AiturkPairDeepLink.pending.collectAsState()
|
||||
LaunchedEffect(deepLink) {
|
||||
if (deepLink != null) AiturkPairDeepLink.take()?.let(viewModel::onDeepLink)
|
||||
}
|
||||
// The ViewModel is Activity-scoped: drop one-shot flags when the screen leaves.
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { viewModel.clearTransientState() }
|
||||
}
|
||||
|
||||
state.confirmAddress?.let { address ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissDeepLink,
|
||||
title = { Text(stringResource(R.string.aiturk_pair_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.aiturk_pair_confirm_message, address)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.confirmDeepLink(lanPermissionGranted(context)) }) {
|
||||
Text(stringResource(R.string.aiturk_pair_connect))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::dismissDeepLink) {
|
||||
Text(stringResource(R.string.aiturk_pair_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().imePadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Computer,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.aiturk_pair_title),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.aiturk_pair_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (hasPermission(context, Manifest.permission.CAMERA)) {
|
||||
launchScanner()
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
},
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.QrCodeScanner, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.aiturk_pair_scan_qr), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.aiturk_pair_or),
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
label = { Text(stringResource(R.string.aiturk_pair_code_label)) },
|
||||
placeholder = { Text(stringResource(R.string.aiturk_pair_code_placeholder)) },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
isError = state.errorRes != null,
|
||||
visualTransformation = PairingCodeTransformation,
|
||||
textStyle =
|
||||
MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Characters,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { viewModel.submitCode(lanPermissionGranted(context)) }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.submitCode(lanPermissionGranted(context)) },
|
||||
enabled = !state.busy && state.code.length == AiturkPairing.CODE_LENGTH,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.aiturk_pair_connect), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
if (state.busy) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
if (state.step == AiturkPairStep.RESOLVING) {
|
||||
R.string.aiturk_pair_progress_resolve
|
||||
} else {
|
||||
R.string.aiturk_pair_progress_connect
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.errorRes?.let { res ->
|
||||
Text(
|
||||
text = stringResource(res),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack, enabled = !state.busy) {
|
||||
Text(stringResource(R.string.aiturk_pair_back))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasPermission(
|
||||
context: Context,
|
||||
permission: String,
|
||||
): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Android 17+ gates LAN hosts behind ACCESS_LOCAL_NETWORK; older releases need nothing. */
|
||||
private fun lanPermissionGranted(context: Context): Boolean =
|
||||
!requiresLocalNetworkPermission(Build.VERSION.SDK_INT, LAN_PROBE_URL) ||
|
||||
hasPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
|
||||
/** Shows `ABCDEFGH` as `ABCD-EFGH` without storing the dash. */
|
||||
private object PairingCodeTransformation : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val raw = text.text
|
||||
val shown = if (raw.length > CODE_GROUP) raw.substring(0, CODE_GROUP) + "-" + raw.substring(CODE_GROUP) else raw
|
||||
val mapping =
|
||||
object : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int = if (offset > CODE_GROUP) offset + 1 else offset
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int = if (offset > CODE_GROUP) offset - 1 else offset
|
||||
}
|
||||
return TransformedText(AnnotatedString(shown), mapping)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package com.m57.hermescontrol.ui.aiturkpair
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkConnectionRegistrar
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairError
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairResult
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairingClient
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairingOffer
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairingPayload
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPayloadError
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPayloadResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class AiturkPairStep { IDLE, RESOLVING, CONNECTING }
|
||||
|
||||
data class AiturkPairUiState(
|
||||
val code: String = "",
|
||||
val step: AiturkPairStep = AiturkPairStep.IDLE,
|
||||
@param:StringRes val errorRes: Int? = null,
|
||||
/** Address of a deep-link pairing waiting for the user's confirmation. */
|
||||
val confirmAddress: String? = null,
|
||||
val lanPermissionNeeded: Boolean = false,
|
||||
val connectionSuccess: Boolean = false,
|
||||
) {
|
||||
val busy: Boolean get() = step != AiturkPairStep.IDLE
|
||||
}
|
||||
|
||||
class AiturkPairViewModel(
|
||||
private val client: AiturkPairingClient = AiturkPairingClient(),
|
||||
private val register: suspend (AiturkPairedDevice) -> Unit = AiturkConnectionRegistrar::register,
|
||||
private val deviceName: String = defaultDeviceName(),
|
||||
) : ViewModel() {
|
||||
private sealed interface Request {
|
||||
data class Code(
|
||||
val code: String,
|
||||
) : Request
|
||||
|
||||
data class Payload(
|
||||
val payload: AiturkPairingPayload,
|
||||
) : Request
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(AiturkPairUiState())
|
||||
val uiState: StateFlow<AiturkPairUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var pending: Request? = null
|
||||
private var confirmPayload: AiturkPairingPayload? = null
|
||||
|
||||
fun onCodeChange(raw: String) {
|
||||
_uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null) }
|
||||
}
|
||||
|
||||
fun submitCode(lanPermissionGranted: Boolean) {
|
||||
val code = AiturkPairing.normalizeCode(_uiState.value.code)
|
||||
if (code == null) {
|
||||
showError(R.string.aiturk_pair_error_code_invalid)
|
||||
return
|
||||
}
|
||||
start(Request.Code(code), lanPermissionGranted)
|
||||
}
|
||||
|
||||
/** [raw] is null when the user backed out of the scanner. */
|
||||
fun onQrScanned(
|
||||
raw: String?,
|
||||
lanPermissionGranted: Boolean,
|
||||
) {
|
||||
if (raw == null) return
|
||||
when (val result = AiturkPairing.parse(raw)) {
|
||||
is AiturkPayloadResult.Ok -> start(Request.Payload(result.payload), lanPermissionGranted)
|
||||
is AiturkPayloadResult.Invalid -> showError(payloadErrorRes(result.error))
|
||||
}
|
||||
}
|
||||
|
||||
/** A link from outside the app is never acted on without an explicit confirmation. */
|
||||
fun onDeepLink(raw: String) {
|
||||
if (_uiState.value.busy) return
|
||||
when (val result = AiturkPairing.parse(raw)) {
|
||||
is AiturkPayloadResult.Ok -> {
|
||||
confirmPayload = result.payload
|
||||
val address =
|
||||
result.payload.offer.endpoints
|
||||
.first()
|
||||
.hostPort
|
||||
_uiState.update { it.copy(confirmAddress = address, errorRes = null) }
|
||||
}
|
||||
|
||||
is AiturkPayloadResult.Invalid -> {
|
||||
showError(payloadErrorRes(result.error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmDeepLink(lanPermissionGranted: Boolean) {
|
||||
val payload = confirmPayload ?: return
|
||||
dismissDeepLink()
|
||||
start(Request.Payload(payload), lanPermissionGranted)
|
||||
}
|
||||
|
||||
fun dismissDeepLink() {
|
||||
confirmPayload = null
|
||||
_uiState.update { it.copy(confirmAddress = null) }
|
||||
}
|
||||
|
||||
fun onCameraPermissionDenied() = showError(R.string.aiturk_pair_error_camera_denied)
|
||||
|
||||
fun onLanPermissionResult(granted: Boolean) {
|
||||
_uiState.update { it.copy(lanPermissionNeeded = false) }
|
||||
val request = pending
|
||||
pending = null
|
||||
if (!granted) {
|
||||
showError(R.string.aiturk_pair_error_lan_permission)
|
||||
} else if (request != null) {
|
||||
run(request)
|
||||
}
|
||||
}
|
||||
|
||||
/** The ViewModel outlives the screen; reset what must not replay on re-entry. */
|
||||
fun clearTransientState() {
|
||||
pending = null
|
||||
confirmPayload = null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorRes = null,
|
||||
confirmAddress = null,
|
||||
lanPermissionNeeded = false,
|
||||
connectionSuccess = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(
|
||||
request: Request,
|
||||
lanPermissionGranted: Boolean,
|
||||
) {
|
||||
if (_uiState.value.busy) return
|
||||
if (!lanPermissionGranted) {
|
||||
pending = request
|
||||
_uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null) }
|
||||
return
|
||||
}
|
||||
run(request)
|
||||
}
|
||||
|
||||
private fun run(request: Request) {
|
||||
if (_uiState.value.busy) return
|
||||
val firstStep = if (request is Request.Code) AiturkPairStep.RESOLVING else AiturkPairStep.CONNECTING
|
||||
_uiState.update { it.copy(step = firstStep, errorRes = null) }
|
||||
viewModelScope.launch {
|
||||
val outcome =
|
||||
try {
|
||||
pair(request)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE)
|
||||
}
|
||||
when (outcome) {
|
||||
is AiturkPairResult.Success -> {
|
||||
val saved = runCatching { register(outcome.value) }.isSuccess
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AiturkPairStep.IDLE,
|
||||
code = "",
|
||||
connectionSuccess = saved,
|
||||
errorRes = if (saved) null else R.string.aiturk_pair_error_save_failed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AiturkPairResult.Failure -> {
|
||||
_uiState.update { it.copy(step = AiturkPairStep.IDLE, errorRes = pairErrorRes(outcome.error)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pair(request: Request): AiturkPairResult<AiturkPairedDevice> {
|
||||
val offer: AiturkPairingOffer
|
||||
val code: String
|
||||
when (request) {
|
||||
is Request.Payload -> {
|
||||
offer = request.payload.offer
|
||||
code = request.payload.code
|
||||
}
|
||||
|
||||
is Request.Code -> {
|
||||
code = request.code
|
||||
when (val resolved = client.resolve(code)) {
|
||||
is AiturkPairResult.Success -> offer = resolved.value
|
||||
is AiturkPairResult.Failure -> return resolved
|
||||
}
|
||||
_uiState.update { it.copy(step = AiturkPairStep.CONNECTING) }
|
||||
}
|
||||
}
|
||||
return client.pair(offer, code, deviceName)
|
||||
}
|
||||
|
||||
private fun showError(
|
||||
@StringRes res: Int,
|
||||
) {
|
||||
_uiState.update { it.copy(errorRes = res) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
@StringRes
|
||||
fun payloadErrorRes(error: AiturkPayloadError): Int =
|
||||
when (error) {
|
||||
AiturkPayloadError.NOT_AITURK -> R.string.aiturk_pair_error_qr_not_aiturk
|
||||
|
||||
AiturkPayloadError.UNSUPPORTED_VERSION -> R.string.aiturk_pair_error_update_app
|
||||
|
||||
AiturkPayloadError.MALFORMED,
|
||||
AiturkPayloadError.BAD_FINGERPRINT,
|
||||
AiturkPayloadError.BAD_ADDRESS,
|
||||
AiturkPayloadError.BAD_ADDRESS_COUNT,
|
||||
AiturkPayloadError.BAD_CODE,
|
||||
-> R.string.aiturk_pair_error_qr_invalid
|
||||
}
|
||||
|
||||
@StringRes
|
||||
fun pairErrorRes(error: AiturkPairError): Int =
|
||||
when (error) {
|
||||
AiturkPairError.CODE_NOT_FOUND -> R.string.aiturk_pair_error_code_not_found
|
||||
|
||||
AiturkPairError.RATE_LIMITED -> R.string.aiturk_pair_error_rate_limited
|
||||
|
||||
AiturkPairError.GATEWAY_UNAVAILABLE -> R.string.aiturk_pair_error_gateway
|
||||
|
||||
AiturkPairError.PC_UNREACHABLE -> R.string.aiturk_pair_error_pc_unreachable
|
||||
|
||||
AiturkPairError.WRONG_CODE -> R.string.aiturk_pair_error_wrong_code
|
||||
|
||||
AiturkPairError.PAIRING_CLOSED -> R.string.aiturk_pair_error_pairing_closed
|
||||
|
||||
AiturkPairError.PAIRING_LOCKED -> R.string.aiturk_pair_error_pairing_locked
|
||||
|
||||
AiturkPairError.INVALID_REQUEST,
|
||||
AiturkPairError.UNEXPECTED_RESPONSE,
|
||||
-> R.string.aiturk_pair_error_unexpected
|
||||
}
|
||||
|
||||
private fun defaultDeviceName(): String {
|
||||
val maker = android.os.Build.MANUFACTURER.orEmpty()
|
||||
val model = android.os.Build.MODEL.orEmpty()
|
||||
val name = if (model.startsWith(maker, ignoreCase = true)) model else "$maker $model"
|
||||
return AiturkPairingClient.sanitizeDeviceName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.m57.hermescontrol.ui.aiturkpair
|
||||
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
|
||||
/**
|
||||
* The ZXing capture screen under our own manifest entry, so it follows the
|
||||
* device orientation instead of the library default (landscape only).
|
||||
*/
|
||||
class AiturkQrCaptureActivity : CaptureActivity()
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -31,6 +32,7 @@ import com.m57.hermescontrol.R
|
||||
fun LandingScreen(
|
||||
onAuthLogin: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onAiturkPair: () -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
@@ -88,6 +90,16 @@ fun LandingScreen(
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onAiturkPair,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.landing_action_aiturk_pair),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onAuthLogin,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user