fix: enhance server URL handling for emulator and physical devices in ApiClient and ServerSetupViewModel
This commit is contained in:
@@ -2,6 +2,8 @@ package com.collabtable.app.data.api
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.collabtable.app.data.preferences.PreferencesManager
|
import com.collabtable.app.data.preferences.PreferencesManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.net.Uri
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
@@ -56,15 +58,54 @@ object ApiClient {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isEmulator(): Boolean {
|
||||||
|
val fingerprint = Build.FINGERPRINT.lowercase()
|
||||||
|
val model = Build.MODEL.lowercase()
|
||||||
|
val brand = Build.BRAND.lowercase()
|
||||||
|
val device = Build.DEVICE.lowercase()
|
||||||
|
val product = Build.PRODUCT.lowercase()
|
||||||
|
return fingerprint.contains("generic") || fingerprint.contains("emulator") ||
|
||||||
|
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") ||
|
||||||
|
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureTrailingSlash(url: String): String = if (url.endsWith("/")) url else "$url/"
|
||||||
|
|
||||||
|
private fun normalizeForAndroidEmulator(rawUrl: String): String {
|
||||||
|
// Remap common hostnames that are unreachable from the Android emulator to host loopback
|
||||||
|
// - localhost / 127.0.0.1 / host.docker.internal -> 10.0.2.2 (emulator-only)
|
||||||
|
// Leave other hosts unchanged.
|
||||||
|
if (!isEmulator()) return ensureTrailingSlash(rawUrl)
|
||||||
|
return try {
|
||||||
|
val uri = Uri.parse(rawUrl)
|
||||||
|
val host = uri.host?.lowercase()
|
||||||
|
val needsRemap = host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal"
|
||||||
|
if (needsRemap) {
|
||||||
|
val scheme = uri.scheme ?: "http"
|
||||||
|
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||||
|
val pathPart = uri.encodedPath ?: "/"
|
||||||
|
val queryPart = if (uri.encodedQuery != null) "?${uri.encodedQuery}" else ""
|
||||||
|
val remapped = "$scheme://10.0.2.2$portPart$pathPart$queryPart"
|
||||||
|
ensureTrailingSlash(if (remapped.contains("/api")) remapped else remapped.trimEnd('/') + "/api/")
|
||||||
|
} else {
|
||||||
|
// Ensure /api/ suffix present
|
||||||
|
val ensuredApi = if (rawUrl.contains("/api")) rawUrl else rawUrl.trimEnd('/') + "/api/"
|
||||||
|
ensureTrailingSlash(ensuredApi)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ensureTrailingSlash(rawUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
this.context = context.applicationContext
|
this.context = context.applicationContext
|
||||||
val prefs = PreferencesManager.getInstance(context)
|
val prefs = PreferencesManager.getInstance(context)
|
||||||
baseUrl = prefs.getServerUrl()
|
baseUrl = normalizeForAndroidEmulator(prefs.getServerUrl())
|
||||||
retrofit = buildRetrofit()
|
retrofit = buildRetrofit()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBaseUrl(url: String) {
|
fun setBaseUrl(url: String) {
|
||||||
baseUrl = if (url.endsWith("/")) url else "$url/"
|
baseUrl = normalizeForAndroidEmulator(url)
|
||||||
retrofit = buildRetrofit()
|
retrofit = buildRetrofit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
@@ -161,6 +161,9 @@ class SyncRepository(context: Context) {
|
|||||||
|
|
||||||
return@withContext Result.success(Unit)
|
return@withContext Result.success(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (e is java.net.UnknownHostException) {
|
||||||
|
Logger.e("Sync", "[ERROR] Unknown host. If you're running the server on the host machine, use 10.0.2.2 on the Android emulator or a LAN IP on a physical device.")
|
||||||
|
}
|
||||||
// If WS indicated unauthorized, align behavior with HTTP path
|
// If WS indicated unauthorized, align behavior with HTTP path
|
||||||
if (e.message?.contains("Unauthorized") == true) {
|
if (e.message?.contains("Unauthorized") == true) {
|
||||||
setLastSyncTimestamp(0)
|
setLastSyncTimestamp(0)
|
||||||
|
|||||||
+21
@@ -18,6 +18,8 @@ import java.net.ConnectException
|
|||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import android.os.Build
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
class ServerSetupViewModel(
|
class ServerSetupViewModel(
|
||||||
private val preferencesManager: PreferencesManager,
|
private val preferencesManager: PreferencesManager,
|
||||||
@@ -90,6 +92,25 @@ class ServerSetupViewModel(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On physical devices, block local-only hosts that won't resolve
|
||||||
|
val isEmulator = run {
|
||||||
|
val fp = Build.FINGERPRINT.lowercase()
|
||||||
|
val model = Build.MODEL.lowercase()
|
||||||
|
val brand = Build.BRAND.lowercase()
|
||||||
|
val device = Build.DEVICE.lowercase()
|
||||||
|
val product = Build.PRODUCT.lowercase()
|
||||||
|
fp.contains("generic") || fp.contains("emulator") ||
|
||||||
|
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") ||
|
||||||
|
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk")
|
||||||
|
}
|
||||||
|
val host = try { Uri.parse(normalizedUrl).host?.lowercase() } catch (_: Exception) { null }
|
||||||
|
val localOnlyHosts = setOf("localhost", "127.0.0.1", "host.docker.internal", "10.0.2.2")
|
||||||
|
if (!isEmulator && host != null && host in localOnlyHosts) {
|
||||||
|
_validationError.value = "On a physical device, use your computer's LAN IP (e.g., 192.168.x.x:3000) instead of '$host'."
|
||||||
|
_isValidating.value = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
// Try to reach the health endpoint (no auth required)
|
// Try to reach the health endpoint (no auth required)
|
||||||
val healthUrl = normalizedUrl.replace("/api/", "/health")
|
val healthUrl = normalizedUrl.replace("/api/", "/health")
|
||||||
val healthRequest =
|
val healthRequest =
|
||||||
|
|||||||
+198
-1
@@ -1,12 +1,14 @@
|
|||||||
package com.collabtable.app.ui.screens
|
package com.collabtable.app.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -34,14 +36,17 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -54,6 +59,11 @@ import com.collabtable.app.utils.Logger
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import android.os.Build
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -77,6 +87,51 @@ fun SettingsScreen(
|
|||||||
var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) }
|
var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) }
|
||||||
var syncIntervalError by remember { mutableStateOf<String?>(null) }
|
var syncIntervalError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Test connection state
|
||||||
|
var isTesting by remember { mutableStateOf(false) }
|
||||||
|
var testOk by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
var testMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var lastLatencyMs by remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
|
val testClient = remember {
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isEmulator(): Boolean {
|
||||||
|
val fp = Build.FINGERPRINT.lowercase()
|
||||||
|
val model = Build.MODEL.lowercase()
|
||||||
|
val brand = Build.BRAND.lowercase()
|
||||||
|
val device = Build.DEVICE.lowercase()
|
||||||
|
val product = Build.PRODUCT.lowercase()
|
||||||
|
return fp.contains("generic") || fp.contains("emulator") ||
|
||||||
|
model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") ||
|
||||||
|
brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toHealthUrl(rawApiUrl: String): String {
|
||||||
|
// Ensure we hit the unauthenticated /health endpoint
|
||||||
|
var url = rawApiUrl
|
||||||
|
// Emu remap for local hosts
|
||||||
|
try {
|
||||||
|
val uri = Uri.parse(url)
|
||||||
|
val host = uri.host?.lowercase()
|
||||||
|
val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal")
|
||||||
|
val scheme = uri.scheme ?: "http"
|
||||||
|
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||||
|
val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart"
|
||||||
|
// Replace any /api or /api/ segment at end with /health
|
||||||
|
val path = (uri.encodedPath ?: "/").trimEnd('/')
|
||||||
|
val healthPath = if (path.endsWith("/api")) "/health" else if (path.endsWith("/api/")) "/health" else "/health"
|
||||||
|
return base + healthPath
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback: naive replace
|
||||||
|
return rawApiUrl.replace("/api/", "/health").replace("/api", "/health")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -86,6 +141,34 @@ fun SettingsScreen(
|
|||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
// Connection status dot: green=ok, red=error, gray=unknown
|
||||||
|
val dotColor = when (testOk) {
|
||||||
|
true -> Color(0xFF2E7D32)
|
||||||
|
false -> MaterialTheme.colorScheme.error
|
||||||
|
null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
if (lastLatencyMs != null && testOk == true) {
|
||||||
|
Text(
|
||||||
|
text = "${'$'}{lastLatencyMs} ms",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||||
|
.background(dotColor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
colors =
|
colors =
|
||||||
TopAppBarDefaults.topAppBarColors(
|
TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
@@ -286,6 +369,97 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection test
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Server connectivity",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
isTesting = true
|
||||||
|
testOk = null
|
||||||
|
testMessage = null
|
||||||
|
lastLatencyMs = null
|
||||||
|
coroutineScope.launch {
|
||||||
|
val rawApi = preferencesManager.getServerUrl()
|
||||||
|
val healthUrl = toHealthUrl(rawApi)
|
||||||
|
val req = Request.Builder().url(healthUrl).get().build()
|
||||||
|
val start = System.nanoTime()
|
||||||
|
try {
|
||||||
|
val resp = withContext(Dispatchers.IO) { testClient.newCall(req).execute() }
|
||||||
|
val tookMs = (System.nanoTime() - start) / 1_000_000
|
||||||
|
if (resp.isSuccessful) {
|
||||||
|
testOk = true
|
||||||
|
lastLatencyMs = tookMs
|
||||||
|
testMessage = "Reachable (${tookMs} ms)"
|
||||||
|
} else {
|
||||||
|
testOk = false
|
||||||
|
testMessage = "HTTP ${resp.code}"
|
||||||
|
}
|
||||||
|
resp.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
testOk = false
|
||||||
|
testMessage = e.message ?: "Connection error"
|
||||||
|
} finally {
|
||||||
|
isTesting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isTesting,
|
||||||
|
) {
|
||||||
|
if (isTesting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Testing…")
|
||||||
|
} else {
|
||||||
|
Text("Test connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (testOk) {
|
||||||
|
true -> Text(
|
||||||
|
text = testMessage ?: "Reachable",
|
||||||
|
color = Color(0xFF2E7D32),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
false -> Text(
|
||||||
|
text = testMessage ?: "Not reachable",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (testOk == false) {
|
||||||
|
Text(
|
||||||
|
text = "Tip: On emulator use http://10.0.2.2:PORT/api/. On a device use your PC's LAN IP.",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors =
|
colors =
|
||||||
@@ -393,8 +567,31 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
// Perform a silent health check when entering the screen to set the indicator
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try {
|
||||||
|
val rawApi = preferencesManager.getServerUrl()
|
||||||
|
val healthUrl = toHealthUrl(rawApi)
|
||||||
|
val req = Request.Builder().url(healthUrl).get().build()
|
||||||
|
val start = System.nanoTime()
|
||||||
|
val resp = withContext(Dispatchers.IO) { testClient.newCall(req).execute() }
|
||||||
|
val tookMs = (System.nanoTime() - start) / 1_000_000
|
||||||
|
if (resp.isSuccessful) {
|
||||||
|
testOk = true
|
||||||
|
lastLatencyMs = tookMs
|
||||||
|
testMessage = "Reachable (${tookMs} ms)"
|
||||||
|
} else {
|
||||||
|
testOk = false
|
||||||
|
testMessage = "HTTP ${resp.code}"
|
||||||
|
}
|
||||||
|
resp.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
testOk = false
|
||||||
|
testMessage = e.message ?: "Connection error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
private fun formatServerUrlForDisplay(raw: String): String {
|
private fun formatServerUrlForDisplay(raw: String): String {
|
||||||
var s = raw.trim()
|
var s = raw.trim()
|
||||||
if (s.isEmpty()) return ""
|
if (s.isEmpty()) return ""
|
||||||
|
|||||||
Reference in New Issue
Block a user