From 9f69b480790051e34b0d44dc6932f57c25f7712a Mon Sep 17 00:00:00 2001 From: gabriel20xx Date: Sun, 26 Oct 2025 16:52:58 +0100 Subject: [PATCH] fix: enhance server URL handling for emulator and physical devices in ApiClient and ServerSetupViewModel --- .../com/collabtable/app/data/api/ApiClient.kt | 45 +++- .../app/data/repository/SyncRepository.kt | 3 + .../app/ui/screens/ServerSetupViewModel.kt | 21 ++ .../app/ui/screens/SettingsScreen.kt | 199 +++++++++++++++++- 4 files changed, 265 insertions(+), 3 deletions(-) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt index 5b6e7c6..4a18950 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt @@ -2,6 +2,8 @@ package com.collabtable.app.data.api import android.content.Context import com.collabtable.app.data.preferences.PreferencesManager +import android.os.Build +import android.net.Uri import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -56,15 +58,54 @@ object ApiClient { .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) { this.context = context.applicationContext val prefs = PreferencesManager.getInstance(context) - baseUrl = prefs.getServerUrl() + baseUrl = normalizeForAndroidEmulator(prefs.getServerUrl()) retrofit = buildRetrofit() } fun setBaseUrl(url: String) { - baseUrl = if (url.endsWith("/")) url else "$url/" + baseUrl = normalizeForAndroidEmulator(url) retrofit = buildRetrofit() } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt index b51827e..0882fbb 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -161,6 +161,9 @@ class SyncRepository(context: Context) { return@withContext Result.success(Unit) } 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 (e.message?.contains("Unauthorized") == true) { setLastSyncTimestamp(0) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt index 0750598..150fe47 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt @@ -18,6 +18,8 @@ import java.net.ConnectException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.concurrent.TimeUnit +import android.os.Build +import android.net.Uri class ServerSetupViewModel( private val preferencesManager: PreferencesManager, @@ -90,6 +92,25 @@ class ServerSetupViewModel( 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) val healthUrl = normalizedUrl.replace("/api/", "/health") val healthRequest = diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt index 761d33d..dfc8519 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt @@ -1,12 +1,14 @@ package com.collabtable.app.ui.screens import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -34,14 +36,17 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -54,6 +59,11 @@ import com.collabtable.app.utils.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch 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) @Composable @@ -77,6 +87,51 @@ fun SettingsScreen( var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) } var syncIntervalError by remember { mutableStateOf(null) } + // Test connection state + var isTesting by remember { mutableStateOf(false) } + var testOk by remember { mutableStateOf(null) } + var testMessage by remember { mutableStateOf(null) } + var lastLatencyMs by remember { mutableStateOf(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( topBar = { TopAppBar( @@ -86,6 +141,34 @@ fun SettingsScreen( 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 = TopAppBarDefaults.topAppBarColors( 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( modifier = Modifier.fillMaxWidth(), 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 { var s = raw.trim() if (s.isEmpty()) return ""