diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6311e266..4ffe7d84 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,6 +12,9 @@
+
+
+
@@ -38,8 +41,23 @@
+
+
+
+
+
+
+
+
+
{
+ 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)
diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt
index 42ec81c3..d8cb2a7b 100644
--- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt
+++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt
@@ -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
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairDeepLink.kt b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairDeepLink.kt
new file mode 100644
index 00000000..0a21196c
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairDeepLink.kt
@@ -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(null)
+ val pending: StateFlow = _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)
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt
new file mode 100644
index 00000000..e3c7c239
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModel.kt
new file mode 100644
index 00000000..65b8701d
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModel.kt
@@ -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 = _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 {
+ 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)
+ }
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkQrCaptureActivity.kt b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkQrCaptureActivity.kt
new file mode 100644
index 00000000..28d5048c
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkQrCaptureActivity.kt
@@ -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()
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt
index 4e54ac05..2738f7e3 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt
@@ -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),
) {
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index b2b78f37..b1b11b2e 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -1862,4 +1862,36 @@
Katalog kaydı yok
Kurulu
Uygulama renklerini Material You sağlarken durum renkleri için kullanılır.
+
+
+ Bilgisayara bağlan (QR / kod)
+ Bilgisayara bağlan
+ 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ı.
+ QR okut
+ Kamerayı AITURK IDE\'deki QR koda tutun
+ ya da
+ 8 karakterli kod
+ ABCD-EFGH
+ Bağlan
+ Vazgeç
+ Geri
+ Kod aranıyor…
+ Bilgisayara bağlanılıyor…
+ Bu bilgisayara bağlanılsın mı?
+ 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.
+ Kamera izni verilmedi. Bunun yerine 8 karakterli kodu yazabilirsiniz.
+ Yerel ağdaki cihazlara erişim izni verilmedi. Bilgisayarınıza bağlanmak için bu izni verin.
+ Bu bir AITURK eşleştirme QR kodu değil.
+ Bu QR kod için AITURK Mobil\'in daha yeni bir sürümü gerekiyor. Lütfen uygulamayı güncelleyin.
+ QR kod eksik ya da bozuk. AITURK IDE\'de Telefon bağlantısı ekranını yeniden açıp tekrar deneyin.
+ Kod 8 karakterdir. AITURK IDE\'de göründüğü gibi yazın.
+ 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.
+ Çok fazla deneme yapıldı. Bir dakika bekleyip tekrar deneyin.
+ Kod servisine ulaşılamadı. İnternet bağlantınızı kontrol edin ya da QR kodu okutun.
+ 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.
+ Kod yanlış. AITURK IDE\'deki kodu kontrol edip tekrar deneyin.
+ 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.
+ Çok fazla yanlış kod girildi, eşleştirme iptal edildi. AITURK IDE\'de Telefon bağlantısını yeniden başlatın.
+ Bilgisayar beklenmeyen bir cevap verdi. AITURK IDE ve AITURK Mobil\'i güncelleyip tekrar deneyin.
+ Eşleşti ama bağlantı kaydedilemedi. Lütfen tekrar deneyin.
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 271c9ab6..85370f57 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1862,4 +1862,36 @@
No catalog entries
Installed
Used for status colors while Material You supplies the app colors.
+
+
+ Connect to computer (QR / code)
+ Connect to your computer
+ 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.
+ Scan QR
+ Point the camera at the QR code in AITURK IDE
+ or
+ 8-character code
+ ABCD-EFGH
+ Connect
+ Cancel
+ Back
+ Looking up the code…
+ Connecting to your computer…
+ Connect to this computer?
+ 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.
+ Camera permission was not granted. You can type the 8-character code instead.
+ Access to devices on your local network was not allowed. Allow it to connect to your computer.
+ This is not an AITURK pairing QR code.
+ This QR code needs a newer AITURK Mobil. Please update the app.
+ The QR code is incomplete or damaged. Open Phone connection in AITURK IDE again and retry.
+ The code has 8 characters. Type it exactly as shown in AITURK IDE.
+ Code not found or expired. Check it, or open Phone connection in AITURK IDE again for a new code.
+ Too many attempts. Wait a minute and try again.
+ The code service could not be reached. Check your internet connection, or scan the QR code instead.
+ 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.
+ Wrong code. Check the code in AITURK IDE and try again.
+ Pairing is closed, or the code expired or was already used. Open Phone connection in AITURK IDE again.
+ Too many wrong codes, pairing was cancelled. Start Phone connection again in AITURK IDE.
+ The computer gave an unexpected answer. Update AITURK IDE and AITURK Mobil, then try again.
+ Paired, but the connection could not be saved. Please try again.