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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
BIN
app/src/main/res/raw/lck_lan.p12
Normal file
BIN
app/src/main/res/raw/lck_lan.p12
Normal file
Binary file not shown.
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">10.0.0.0/8</domain>
|
||||
<domain includeSubdomains="true">192.168.0.0/16</domain>
|
||||
<domain includeSubdomains="true">172.16.0.0/12</domain>
|
||||
</domain-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user