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)
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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",
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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"?>
|
<?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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user