From b3522bc1c49dd0d4ed25c9df72d7abd96f7aed7d Mon Sep 17 00:00:00 2001 From: omigamedev Date: Wed, 4 Mar 2026 13:05:38 +0100 Subject: [PATCH] Add LAN auto-discovery pairing with shared TLS certificate Login screen now scans for Quest on LAN via NSD, auto-pairs by requesting a pairing code from the Quest's TLS server, and falls back to manual code entry. Both apps share a pinned self-signed certificate for secure LAN communication. Also adds Moshi codegen KSP processor and disables cleartext. --- app/build.gradle.kts | 1 + .../lckcontrol/app/p2p/LanTlsFactory.kt | 36 +++++ .../lckcontrol/app/ui/login/LoginScreen.kt | 146 ++++++++++++++++-- .../lckcontrol/app/ui/login/LoginViewModel.kt | 144 ++++++++++++++++- app/src/main/res/raw/lck_lan.p12 | Bin 0 -> 1094 bytes .../main/res/xml/network_security_config.xml | 10 +- gradle/libs.versions.toml | 1 + 7 files changed, 321 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/LanTlsFactory.kt create mode 100644 app/src/main/res/raw/lck_lan.p12 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b14ebe6..d568403 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { implementation(libs.retrofit) implementation(libs.retrofit.converter.moshi) implementation(libs.moshi.kotlin) + ksp(libs.moshi.codegen) implementation(libs.okhttp) implementation(libs.okhttp.logging) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/LanTlsFactory.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/LanTlsFactory.kt new file mode 100644 index 0000000..7884f3b --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/LanTlsFactory.kt @@ -0,0 +1,36 @@ +package com.omixlab.lckcontrol.app.p2p + +import android.content.Context +import com.omixlab.lckcontrol.app.R +import java.security.KeyStore +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +/** + * Provides an SSLSocketFactory and TrustManager that trust only the shared + * LCK LAN certificate (lck_lan.p12). Used for all phone→Quest LAN HTTPS calls. + */ +object LanTlsFactory { + + private const val KEYSTORE_PASSWORD = "lckcontrol" + + fun create(context: Context): Pair { + val keyStore = KeyStore.getInstance("PKCS12") + context.resources.openRawResource(R.raw.lck_lan).use { stream -> + keyStore.load(stream, KEYSTORE_PASSWORD.toCharArray()) + } + + // Build a TrustManager that only trusts the embedded cert + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(keyStore) + val trustManager = tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, tmf.trustManagers, null) + + return sslContext.socketFactory to trustManager + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginScreen.kt index 75e89a0..7a14e0a 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginScreen.kt @@ -1,7 +1,12 @@ package com.omixlab.lckcontrol.app.ui.login +import androidx.compose.animation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Headset +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiFind import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -38,8 +43,117 @@ fun LoginScreen( color = MaterialTheme.colorScheme.primary, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(48.dp)) + AnimatedContent( + targetState = uiState.phase, + label = "login-phase", + ) { phase -> + when (phase) { + LoginPhase.SCANNING -> ScanningContent( + status = uiState.autoStatus, + onSkip = viewModel::switchToManual, + ) + LoginPhase.FOUND_QUEST -> FoundQuestContent( + status = uiState.autoStatus, + deviceName = uiState.discoveredDevice?.deviceModel + ?: uiState.discoveredDevice?.name ?: "Quest", + ) + LoginPhase.MANUAL_CODE -> ManualCodeContent( + code = uiState.code, + isLoading = uiState.isLoading, + error = uiState.error, + onCodeChanged = viewModel::onCodeChanged, + onScanAgain = viewModel::startScan, + ) + } + } + } +} + +@Composable +private fun ScanningContent( + status: String, + onSkip: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.WifiFind, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(64.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = status, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + TextButton(onClick = onSkip) { + Text("Enter code manually") + } + } +} + +@Composable +private fun FoundQuestContent( + status: String, + deviceName: String, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.Headset, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(64.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = status, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp, + ) + } +} + +@Composable +private fun ManualCodeContent( + code: String, + isLoading: Boolean, + error: String?, + onCodeChanged: (String) -> Unit, + onScanAgain: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( text = "Enter the pairing code shown on your Quest headset", style = MaterialTheme.typography.bodyMedium, @@ -47,11 +161,11 @@ fun LoginScreen( textAlign = TextAlign.Center, ) - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(32.dp)) OutlinedTextField( - value = uiState.code, - onValueChange = viewModel::onCodeChanged, + value = code, + onValueChange = onCodeChanged, label = { Text("Pairing Code") }, placeholder = { Text("000000") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -61,20 +175,32 @@ fun LoginScreen( letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.3, ), modifier = Modifier.width(240.dp), - enabled = !uiState.isLoading, - isError = uiState.error != null, - supportingText = uiState.error?.let { error -> - { Text(error, color = MaterialTheme.colorScheme.error) } + enabled = !isLoading, + isError = error != null, + supportingText = error?.let { err -> + { Text(err, color = MaterialTheme.colorScheme.error) } }, ) Spacer(modifier = Modifier.height(24.dp)) - if (uiState.isLoading) { + if (isLoading) { CircularProgressIndicator() } - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(32.dp)) + + TextButton(onClick = onScanAgain) { + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scan for Quest again") + } + + Spacer(modifier = Modifier.height(16.dp)) Text( text = "Open your Quest app and go to Settings > Pair Phone to get a code", diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginViewModel.kt index bcb387e..f31011b 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginViewModel.kt @@ -1,30 +1,168 @@ package com.omixlab.lckcontrol.app.ui.login +import android.content.Context +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.omixlab.lckcontrol.app.data.repository.AuthRepository +import com.omixlab.lckcontrol.app.p2p.LanTlsFactory +import com.omixlab.lckcontrol.app.p2p.discovery.DiscoveredDevice +import com.omixlab.lckcontrol.app.p2p.discovery.LanDiscoveryManager import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.URL import javax.inject.Inject +import javax.net.ssl.HttpsURLConnection + +enum class LoginPhase { + SCANNING, // Looking for Quest on LAN + FOUND_QUEST, // Quest discovered, auto-pairing + MANUAL_CODE, // Fallback: enter code manually +} data class LoginUiState( + val phase: LoginPhase = LoginPhase.SCANNING, val code: String = "", val isLoading: Boolean = false, val error: String? = null, val loginSuccess: Boolean = false, + val discoveredDevice: DiscoveredDevice? = null, + val autoStatus: String = "Searching for Quest on your network...", ) @HiltViewModel class LoginViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val authRepository: AuthRepository, + private val lanDiscoveryManager: LanDiscoveryManager, ) : ViewModel() { + companion object { + private const val TAG = "LoginViewModel" + private const val SCAN_TIMEOUT_MS = 8_000L + } + private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var scanJob: Job? = null + private val lanTls by lazy { LanTlsFactory.create(context) } + + init { + startScan() + } + + fun startScan() { + scanJob?.cancel() + _uiState.value = LoginUiState(phase = LoginPhase.SCANNING) + lanDiscoveryManager.startDiscovery() + + scanJob = viewModelScope.launch { + val deadline = System.currentTimeMillis() + SCAN_TIMEOUT_MS + while (System.currentTimeMillis() < deadline) { + val devices = lanDiscoveryManager.devices.value + if (devices.isNotEmpty()) { + val quest = devices.first() + lanDiscoveryManager.stopDiscovery() + _uiState.value = _uiState.value.copy( + phase = LoginPhase.FOUND_QUEST, + discoveredDevice = quest, + autoStatus = "Found ${quest.deviceModel ?: quest.name}! Pairing...", + ) + autoPairWithQuest(quest) + return@launch + } + delay(500) + } + // Timeout — fall back to manual + lanDiscoveryManager.stopDiscovery() + _uiState.value = _uiState.value.copy(phase = LoginPhase.MANUAL_CODE) + } + } + + private fun autoPairWithQuest(device: DiscoveredDevice) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + try { + val code = withContext(Dispatchers.IO) { + requestPairingCode(device) + } + if (code != null) { + _uiState.value = _uiState.value.copy( + autoStatus = "Got code from Quest, authenticating...", + ) + authRepository.redeemPairingCode(code) + _uiState.value = _uiState.value.copy( + isLoading = false, + loginSuccess = true, + ) + } else { + _uiState.value = _uiState.value.copy( + phase = LoginPhase.MANUAL_CODE, + isLoading = false, + error = "Could not auto-pair. Enter the code manually.", + ) + } + } catch (e: Exception) { + Log.e(TAG, "Auto-pair failed", e) + _uiState.value = _uiState.value.copy( + phase = LoginPhase.MANUAL_CODE, + isLoading = false, + error = "Auto-pair failed. Enter the code manually.", + ) + } + } + } + + private fun requestPairingCode(device: DiscoveredDevice): String? { + return try { + val url = URL("https://${device.ip}:${device.port}/auth-pair") + val conn = url.openConnection() as HttpsURLConnection + val (sslFactory, _) = lanTls + conn.sslSocketFactory = sslFactory + // Skip hostname verification — we trust the pinned cert, IP is dynamic + conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.connectTimeout = 5_000 + conn.readTimeout = 10_000 + conn.doOutput = true + conn.outputStream.write("{}".toByteArray()) + + if (conn.responseCode == 200) { + val body = conn.inputStream.bufferedReader().readText() + conn.disconnect() + val json = JSONObject(body) + json.getString("code") + } else { + Log.e(TAG, "Quest auth-pair returned ${conn.responseCode}") + conn.disconnect() + null + } + } catch (e: Exception) { + Log.e(TAG, "Failed to reach Quest LAN server", e) + null + } + } + + fun switchToManual() { + scanJob?.cancel() + lanDiscoveryManager.stopDiscovery() + _uiState.value = _uiState.value.copy( + phase = LoginPhase.MANUAL_CODE, + error = null, + ) + } + fun onCodeChanged(newCode: String) { if (newCode.length <= 6 && newCode.all { it.isDigit() }) { _uiState.value = _uiState.value.copy(code = newCode, error = null) @@ -50,7 +188,9 @@ class LoginViewModel @Inject constructor( } } - fun clearError() { - _uiState.value = _uiState.value.copy(error = null) + override fun onCleared() { + super.onCleared() + scanJob?.cancel() + lanDiscoveryManager.stopDiscovery() } } diff --git a/app/src/main/res/raw/lck_lan.p12 b/app/src/main/res/raw/lck_lan.p12 new file mode 100644 index 0000000000000000000000000000000000000000..074daff867f923b37cba100fbf62f9eff1489c32 GIT binary patch literal 1094 zcmXqLVsT<(WHxAGe#6G8)#lOmotKfFaX}OFU6v;1n?T{K22G4QC{m2FEKQ7(Kw(iJ z7G&dw>f+&IWLnU;*PwBiK^j~=E3ZMMfdzugW1!6la;|hmW?x^&4V$OnT1h{MIh+Ufn}k0?k_Li8vor)A^aCh zz?pRk>vyCc`J1q7!T}{2ZXf5+3k)Ch)g)eUb0?+=IGxnvni8>4xzkW=@w%|-pKe;5 zFVQ&e@#W4YUvIm3odcz(f{!0F4^Dmi<*%`*yyLg&zYU#0Rzf|*DPkxf$H$Pvkj#+H zpbMlE8S)Gi5z?ZDA}m57nYpP7hUVr*W=6(FCI+UKMg~nx3*m~`*%mZ0O#%w_F)=a# zsdj`6BcWhoy(hZoFiWQUWVt-ywUVJzRg~teB_^1xPv4g++J3@uozkJ*8jH+T8k(2{ z6crvGX4>4}#AY08FWnlGq4~r_qCeCsr1fD>LHxC|_cuIl4{2D%*m-e<=AlO&6F;)7 zI*@qpB$s5e@$I72#}e{JvE0rTn#+7xwzIn)*`yyNmGJPK)zKv@YvZ+;9FBE;gB|W<+Q`DJR_b--1^tE+NiD~}71v~fJ#lGpj z{)K;KzsQR>75c84)0td$M!#12drRQH${GEg!B5j=Uc5O~dMfpo0r%-*x0~@ws~U{m z&DY+nliR;cM&9IwrE|maYr&JtcpSO*{{Q%MUP{x#jWbUDZY^m_W`FZFD6Wd_Kt^C` za);|&@hvImYxv%l8!Y&L-+f(7j)t(|;Q)E*#(%eEd3229&$#n5w@l_-`ah!1er3nc ziOW>;F8GHP%`Rd5KIw5`a=|k%>4ztNzUIu6@_6uYdV3bH;oIZFQ#Cv$NuCnu*}n92 zZcpq4aiNCyOZMG-ckC&n2S>edK;4XrTSa+c7uPsPCkl!znbUQvs`y8W)VJ zx*2?#PO^8eT4yTOKIv|umB>Er)$6S)aclAV-mgxnW;ru7UIP=qc7Z)>6O21on z@rUQKze#fhjMgdJJURVfp>ylsN!{KNOnQz}Ztj2ZN5a9?_$TA3FuIcJ(MkKxxh(e59?B^Tcs_!<}*@WOK!6C*1Fi$bJy z1goh{$6Cp>Lm3q{TIbl!{syb~D`s@F8@Rn}Ts@6NWKWCahLwsFColWj`QzfkTF%Gn JzuG`a5&+$9u5JJT literal 0 HcmV?d00001 diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 336cea5..683208f 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,8 +1,8 @@ - - 10.0.0.0/8 - 192.168.0.0/16 - 172.16.0.0/12 - + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9e2fa7..cdf982e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,7 @@ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" } moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" } +moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }