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 0000000..074daff Binary files /dev/null and b/app/src/main/res/raw/lck_lan.p12 differ 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" }