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:
yilsemandClaude Opus 5.5 committed 2026-10-04 06:06:31 +03:00
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),
) {