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

+18
View File
@@ -12,6 +12,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- AITURK IDE pairing: QR scan. Requested at runtime; typing the code works without it. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<queries>
<intent>
@@ -38,8 +41,23 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- AITURK IDE pairing link: aiturk://pair?v=1&amp;u=...&amp;fp=...&amp;c=... -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiturk" android:host="pair" />
</intent-filter>
</activity>
<activity
android:name=".ui.aiturkpair.AiturkQrCaptureActivity"
android:exported="false"
android:screenOrientation="fullSensor"
android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden" />
<service
android:name=".assistant.AssistantService"
android:permission="android.permission.BIND_VOICE_INTERACTION"
@@ -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),
) {
+32
View File
@@ -1862,4 +1862,36 @@
<string name="mcp_servers_catalog_empty_title">Katalog kaydı yok</string>
<string name="mcp_servers_catalog_installed">Kurulu</string>
<string name="settings_desc_theme_preset_dynamic">Uygulama renklerini Material You sağlarken durum renkleri için kullanılır.</string>
<!-- AITURK IDE pairing (QR / code) -->
<string name="landing_action_aiturk_pair">Bilgisayara bağlan (QR / kod)</string>
<string name="aiturk_pair_title">Bilgisayara bağlan</string>
<string name="aiturk_pair_desc">Bilgisayarınızda AITURK IDE → Ayarlar → Telefon bağlantısı ekranını açın. Orada görünen QR kodu okutun ya da 8 karakterli kodu yazın. Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.</string>
<string name="aiturk_pair_scan_qr">QR okut</string>
<string name="aiturk_pair_scan_prompt">Kamerayı AITURK IDE\'deki QR koda tutun</string>
<string name="aiturk_pair_or">ya da</string>
<string name="aiturk_pair_code_label">8 karakterli kod</string>
<string name="aiturk_pair_code_placeholder">ABCD-EFGH</string>
<string name="aiturk_pair_connect">Bağlan</string>
<string name="aiturk_pair_cancel">Vazgeç</string>
<string name="aiturk_pair_back">Geri</string>
<string name="aiturk_pair_progress_resolve">Kod aranıyor…</string>
<string name="aiturk_pair_progress_connect">Bilgisayara bağlanılıyor…</string>
<string name="aiturk_pair_confirm_title">Bu bilgisayara bağlanılsın mı?</string>
<string name="aiturk_pair_confirm_message">Bir bağlantı, AITURK Mobil\'in ağınızdaki %1$s adresli bilgisayara bağlanmasını istiyor. Yalnızca bu bağlantıyı AITURK IDE\'den kendiniz açtıysanız devam edin.</string>
<string name="aiturk_pair_error_camera_denied">Kamera izni verilmedi. Bunun yerine 8 karakterli kodu yazabilirsiniz.</string>
<string name="aiturk_pair_error_lan_permission">Yerel ağdaki cihazlara erişim izni verilmedi. Bilgisayarınıza bağlanmak için bu izni verin.</string>
<string name="aiturk_pair_error_qr_not_aiturk">Bu bir AITURK eşleştirme QR kodu değil.</string>
<string name="aiturk_pair_error_update_app">Bu QR kod için AITURK Mobil\'in daha yeni bir sürümü gerekiyor. Lütfen uygulamayı güncelleyin.</string>
<string name="aiturk_pair_error_qr_invalid">QR kod eksik ya da bozuk. AITURK IDE\'de Telefon bağlantısı ekranını yeniden açıp tekrar deneyin.</string>
<string name="aiturk_pair_error_code_invalid">Kod 8 karakterdir. AITURK IDE\'de göründüğü gibi yazın.</string>
<string name="aiturk_pair_error_code_not_found">Kod bulunamadı ya da süresi doldu. Kodu kontrol edin ya da yeni kod için AITURK IDE\'de Telefon bağlantısı ekranını yeniden açın.</string>
<string name="aiturk_pair_error_rate_limited">Çok fazla deneme yapıldı. Bir dakika bekleyip tekrar deneyin.</string>
<string name="aiturk_pair_error_gateway">Kod servisine ulaşılamadı. İnternet bağlantınızı kontrol edin ya da QR kodu okutun.</string>
<string name="aiturk_pair_error_pc_unreachable">Bilgisayarınıza ulaşılamadı. Telefon ile bilgisayarın aynı Wi-Fi ağında olduğundan ve AITURK IDE\'de Telefon bağlantısı ekranının açık olduğundan emin olun.</string>
<string name="aiturk_pair_error_wrong_code">Kod yanlış. AITURK IDE\'deki kodu kontrol edip tekrar deneyin.</string>
<string name="aiturk_pair_error_pairing_closed">Eşleştirme kapalı ya da kodun süresi dolmuş veya kod kullanılmış. AITURK IDE\'de Telefon bağlantısı ekranını yeniden açın.</string>
<string name="aiturk_pair_error_pairing_locked">Çok fazla yanlış kod girildi, eşleştirme iptal edildi. AITURK IDE\'de Telefon bağlantısını yeniden başlatın.</string>
<string name="aiturk_pair_error_unexpected">Bilgisayar beklenmeyen bir cevap verdi. AITURK IDE ve AITURK Mobil\'i güncelleyip tekrar deneyin.</string>
<string name="aiturk_pair_error_save_failed">Eşleşti ama bağlantı kaydedilemedi. Lütfen tekrar deneyin.</string>
</resources>
+32
View File
@@ -1862,4 +1862,36 @@
<string name="mcp_servers_catalog_empty_title">No catalog entries</string>
<string name="mcp_servers_catalog_installed">Installed</string>
<string name="settings_desc_theme_preset_dynamic">Used for status colors while Material You supplies the app colors.</string>
<!-- AITURK IDE pairing (QR / code) -->
<string name="landing_action_aiturk_pair">Connect to computer (QR / code)</string>
<string name="aiturk_pair_title">Connect to your computer</string>
<string name="aiturk_pair_desc">On your computer open AITURK IDE → Settings → Phone connection. Scan the QR code shown there or type the 8-character code. The phone and the computer must be on the same Wi-Fi.</string>
<string name="aiturk_pair_scan_qr">Scan QR</string>
<string name="aiturk_pair_scan_prompt">Point the camera at the QR code in AITURK IDE</string>
<string name="aiturk_pair_or">or</string>
<string name="aiturk_pair_code_label">8-character code</string>
<string name="aiturk_pair_code_placeholder">ABCD-EFGH</string>
<string name="aiturk_pair_connect">Connect</string>
<string name="aiturk_pair_cancel">Cancel</string>
<string name="aiturk_pair_back">Back</string>
<string name="aiturk_pair_progress_resolve">Looking up the code…</string>
<string name="aiturk_pair_progress_connect">Connecting to your computer…</string>
<string name="aiturk_pair_confirm_title">Connect to this computer?</string>
<string name="aiturk_pair_confirm_message">A link is asking AITURK Mobil to connect to the computer at %1$s on your network. Continue only if you opened this link yourself from AITURK IDE.</string>
<string name="aiturk_pair_error_camera_denied">Camera permission was not granted. You can type the 8-character code instead.</string>
<string name="aiturk_pair_error_lan_permission">Access to devices on your local network was not allowed. Allow it to connect to your computer.</string>
<string name="aiturk_pair_error_qr_not_aiturk">This is not an AITURK pairing QR code.</string>
<string name="aiturk_pair_error_update_app">This QR code needs a newer AITURK Mobil. Please update the app.</string>
<string name="aiturk_pair_error_qr_invalid">The QR code is incomplete or damaged. Open Phone connection in AITURK IDE again and retry.</string>
<string name="aiturk_pair_error_code_invalid">The code has 8 characters. Type it exactly as shown in AITURK IDE.</string>
<string name="aiturk_pair_error_code_not_found">Code not found or expired. Check it, or open Phone connection in AITURK IDE again for a new code.</string>
<string name="aiturk_pair_error_rate_limited">Too many attempts. Wait a minute and try again.</string>
<string name="aiturk_pair_error_gateway">The code service could not be reached. Check your internet connection, or scan the QR code instead.</string>
<string name="aiturk_pair_error_pc_unreachable">Your computer could not be reached. Make sure the phone and the computer are on the same Wi-Fi and Phone connection is open in AITURK IDE.</string>
<string name="aiturk_pair_error_wrong_code">Wrong code. Check the code in AITURK IDE and try again.</string>
<string name="aiturk_pair_error_pairing_closed">Pairing is closed, or the code expired or was already used. Open Phone connection in AITURK IDE again.</string>
<string name="aiturk_pair_error_pairing_locked">Too many wrong codes, pairing was cancelled. Start Phone connection again in AITURK IDE.</string>
<string name="aiturk_pair_error_unexpected">The computer gave an unexpected answer. Update AITURK IDE and AITURK Mobil, then try again.</string>
<string name="aiturk_pair_error_save_failed">Paired, but the connection could not be saved. Please try again.</string>
</resources>