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.
This commit is contained in:
2026-03-04 13:05:38 +01:00
parent 6f02d33b97
commit b3522bc1c4
7 changed files with 321 additions and 17 deletions

View File

@@ -100,6 +100,7 @@ dependencies {
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.converter.moshi) implementation(libs.retrofit.converter.moshi)
implementation(libs.moshi.kotlin) implementation(libs.moshi.kotlin)
ksp(libs.moshi.codegen)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)

View File

@@ -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<SSLSocketFactory, X509TrustManager> {
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
}
}

View File

@@ -1,7 +1,12 @@
package com.omixlab.lckcontrol.app.ui.login package com.omixlab.lckcontrol.app.ui.login
import androidx.compose.animation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions 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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -38,8 +43,117 @@ fun LoginScreen(
color = MaterialTheme.colorScheme.primary, 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(
text = "Enter the pairing code shown on your Quest headset", text = "Enter the pairing code shown on your Quest headset",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -47,11 +161,11 @@ fun LoginScreen(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField( OutlinedTextField(
value = uiState.code, value = code,
onValueChange = viewModel::onCodeChanged, onValueChange = onCodeChanged,
label = { Text("Pairing Code") }, label = { Text("Pairing Code") },
placeholder = { Text("000000") }, placeholder = { Text("000000") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@@ -61,20 +175,32 @@ fun LoginScreen(
letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.3, letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.3,
), ),
modifier = Modifier.width(240.dp), modifier = Modifier.width(240.dp),
enabled = !uiState.isLoading, enabled = !isLoading,
isError = uiState.error != null, isError = error != null,
supportingText = uiState.error?.let { error -> supportingText = error?.let { err ->
{ Text(error, color = MaterialTheme.colorScheme.error) } { Text(err, color = MaterialTheme.colorScheme.error) }
}, },
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
if (uiState.isLoading) { if (isLoading) {
CircularProgressIndicator() 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(
text = "Open your Quest app and go to Settings > Pair Phone to get a code", text = "Open your Quest app and go to Settings > Pair Phone to get a code",

View File

@@ -1,30 +1,168 @@
package com.omixlab.lckcontrol.app.ui.login package com.omixlab.lckcontrol.app.ui.login
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.app.data.repository.AuthRepository 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.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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.URL
import javax.inject.Inject 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( data class LoginUiState(
val phase: LoginPhase = LoginPhase.SCANNING,
val code: String = "", val code: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val loginSuccess: Boolean = false, val loginSuccess: Boolean = false,
val discoveredDevice: DiscoveredDevice? = null,
val autoStatus: String = "Searching for Quest on your network...",
) )
@HiltViewModel @HiltViewModel
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val lanDiscoveryManager: LanDiscoveryManager,
) : ViewModel() { ) : ViewModel() {
companion object {
private const val TAG = "LoginViewModel"
private const val SCAN_TIMEOUT_MS = 8_000L
}
private val _uiState = MutableStateFlow(LoginUiState()) private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() val uiState: StateFlow<LoginUiState> = _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) { fun onCodeChanged(newCode: String) {
if (newCode.length <= 6 && newCode.all { it.isDigit() }) { if (newCode.length <= 6 && newCode.all { it.isDigit() }) {
_uiState.value = _uiState.value.copy(code = newCode, error = null) _uiState.value = _uiState.value.copy(code = newCode, error = null)
@@ -50,7 +188,9 @@ class LoginViewModel @Inject constructor(
} }
} }
fun clearError() { override fun onCleared() {
_uiState.value = _uiState.value.copy(error = null) super.onCleared()
scanJob?.cancel()
lanDiscoveryManager.stopDiscovery()
} }
} }

Binary file not shown.

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config> <network-security-config>
<domain-config cleartextTrafficPermitted="true"> <base-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">10.0.0.0/8</domain> <trust-anchors>
<domain includeSubdomains="true">192.168.0.0/16</domain> <certificates src="system" />
<domain includeSubdomains="true">172.16.0.0/12</domain> </trust-anchors>
</domain-config> </base-config>
</network-security-config> </network-security-config>

View File

@@ -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 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", 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-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 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }