From 6f4309c74623ede7bc73b1c8f09e98befe68424e Mon Sep 17 00:00:00 2001 From: yilsem Date: Sun, 4 Oct 2026 06:06:31 +0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf --- app/src/main/AndroidManifest.xml | 18 ++ .../com/m57/hermescontrol/MainActivity.kt | 3 + .../java/com/m57/hermescontrol/Navigation.kt | 18 ++ .../com/m57/hermescontrol/NavigationKeys.kt | 2 + .../ui/aiturkpair/AiturkPairDeepLink.kt | 45 +++ .../ui/aiturkpair/AiturkPairScreen.kt | 291 ++++++++++++++++++ .../ui/aiturkpair/AiturkPairViewModel.kt | 259 ++++++++++++++++ .../ui/aiturkpair/AiturkQrCaptureActivity.kt | 9 + .../hermescontrol/ui/landing/LandingScreen.kt | 12 + app/src/main/res/values-tr/strings.xml | 32 ++ app/src/main/res/values/strings.xml | 32 ++ 11 files changed, 721 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairDeepLink.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkQrCaptureActivity.kt 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.