diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ad541b..724265d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -76,17 +76,21 @@ - + 3. omnitak://… deep links (our own future-proof scheme: + server-connect AND config-profile sync). --> + diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/MainActivity.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/MainActivity.kt index c165000..92e8f70 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/MainActivity.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/MainActivity.kt @@ -76,10 +76,10 @@ class MainActivity : ComponentActivity() { } /** - * GAP-105 rest — handle `atak://` / `omnitak://` deep links carrying - * a server-onboarding payload. Singletask launchMode means a second - * scan while the app is open re-enters via [onNewIntent] instead of - * spawning a new task. + * GAP-105 rest / #100 — handle `tak://` / `atak://` / `omnitak://` deep + * links carrying a server-onboarding payload (enrollment QR, connect link, + * or config profile). Singletask launchMode means a second scan while the + * app is open re-enters via [onNewIntent] instead of spawning a new task. */ private fun handleImportIntent(intent: Intent?) { if (intent?.action != Intent.ACTION_VIEW) return @@ -103,6 +103,25 @@ class MainActivity : ComponentActivity() { return } + // #100 — the standard ATAK / TAK Server / ArgusTAK enrollment QR + // (`tak://…/enroll?host=&username=&token=`). Check BEFORE isServerConfig: + // an `atak://…/enroll` link also satisfies the connect-form matcher, and + // we want it to CSR-enroll (token = enrollment secret) rather than be + // added cert-less and rejected at the mTLS handshake. + if (DeepLinkImport.isEnrollLink(uri)) { + val enrollCfg = DeepLinkImport.parseEnrollLink(uri) + if (enrollCfg == null) { + Toast.makeText( + this, + "Enrollment link missing host, username, or token", + Toast.LENGTH_LONG, + ).show() + return + } + enrollFromDeepLink(uri, enrollCfg) + return + } + if (!DeepLinkImport.isServerConfig(uri)) return val cfg = DeepLinkImport.parseServerConfig(uri) @@ -169,6 +188,11 @@ class MainActivity : ComponentActivity() { password = null, certificateName = enrolled.certificateName, certificatePassword = enrolled.certificatePassword, + // Pin the enrollment CA so the connection validates the server's + // private-CA cert (ArgusTAK). Without this the connect path falls + // back to the system trust store and fails CertPathValidator. + // Matches EnrollServerScreen / ServerQrScanScreen. + caCertificateName = enrolled.caCertificateName, ), ) Log.i("OmniTAK", "Enrolled + added server '${cfg.name}' from $uri") diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/DeepLinkImport.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/DeepLinkImport.kt index 60c6ffa..e589d6b 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/DeepLinkImport.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/DeepLinkImport.kt @@ -8,21 +8,23 @@ import android.net.Uri * the resulting URL deep-links into the app, we parse it and apply the * server config in one tap. * - * Three URL shapes we accept today: + * URL shapes we accept today: * - * 1. `atak://com.atakmap.app/connect?host=tak.example.com&port=8089&proto=tls` + * 1. `tak://com.atakmap.app/enroll?host=argustak.com%3A8089&username=u&token=t` + * — the STANDARD ATAK / TAK Server / ArgusTAK enrollment QR (#100). The + * `host` carries the (URL-encoded) connection `host:port`; `username` + + * `token` are one-time CSR-enrollment credentials. Parsed by + * [isEnrollLink] / [parseEnrollLink] and routed to the CSR enroll flow. + * + * 2. `atak://com.atakmap.app/connect?host=tak.example.com&port=8089&proto=tls` * — matches the de-facto ATAK Quick-Connect format some onboarding * portals already generate. We tolerate either `tls=true` or * `proto=tls`. ATAK's own client also reads `username` / `token`. * - * 2. `omnitak://server?host=...&port=...&tls=true&user=...&pw=...&name=...` + * 3. `omnitak://server?host=...&port=...&tls=true&user=...&pw=...&name=...` * — our own future-proof scheme so we don't have to depend on ATAK's * URL conventions for new fields (callsign, team, basemap, etc.). * - * 3. Anything that has a `host` query param and looks like a TAK endpoint — - * used as a generic fallback so OpenTAKserver onboarding portals can - * point at any URL of the form `https://example.com/?host=...`. - * * The parser intentionally avoids cert / data-package zip handling for * now — that's filed as the next iteration of GAP-105 (full ATAK * data-package import with embedded P12 client certs). @@ -78,6 +80,133 @@ object DeepLinkImport { return !uri.getQueryParameter("host").isNullOrBlank() } + /** + * #100 — recognise the *standard* ATAK / TAK Server / ArgusTAK + * enrollment deep link: + * + * `tak://com.atakmap.app/enroll?host=[:port]&username=&token=` + * + * This is what ArgusTAK / ATAK / TAK Server emit in their onboarding QR + * codes. It is shaped differently from our [isServerConfig] connect form: + * - the path is `/enroll` (the connect form has no path), + * - the `host` query param frequently carries `host:port` (URL-encoded, + * e.g. `argustak.com%3A8089`) rather than a separate `port=`, + * - the credential is a one-time CSR-enrollment `token`, not a password. + * + * Accepts the `tak`, `atak`, and `omnitak` schemes so the same QR works + * whether the generator used ATAK's canonical `tak://` host + * (`com.atakmap.app`) or just the scheme. HTTP/HTTPS are intentionally + * excluded — same drive-by-injection guard as [isServerConfig]. + */ + fun isEnrollLink(uri: Uri?): Boolean { + if (uri == null) return false + val scheme = uri.scheme?.lowercase() ?: return false + if (scheme !in setOf("tak", "atak", "omnitak")) return false + // The path is the discriminator: `…/enroll`. Uri.path can be null for + // opaque URIs, so fall back to a substring probe on the raw string. + val isEnrollPath = uri.path?.trimEnd('/')?.endsWith("enroll", ignoreCase = true) == true || + uri.toString().contains("/enroll", ignoreCase = true) + if (!isEnrollPath) return false + return !uri.getQueryParameter("host").isNullOrBlank() + } + + /** + * Parse a [isEnrollLink] enrollment URI into an [ImportedServerConfig] + * ready for the CSR enrollment flow. Returns null when the link is + * missing the host or enrollment credentials (the caller should toast a + * friendly error rather than silently dropping the import). + * + * Field mapping: + * - `host` → split on `:` so `argustak.com:8089` yields host + * `argustak.com` + **connection** port 8089. A bare host with no + * colon defaults the connection port to 8089 (TAK streaming default). + * - `username` → CSR auth username (becomes the cert CN). + * - `token` → CSR auth secret. Carried in [ImportedServerConfig.password] + * because [CSREnrollmentService] sends username+secret as HTTP Basic + * auth to `/Marti/api/tls/signClient/v2` — the token IS the password + * on the enrollment endpoint. + * - enrollment port → defaults to 8446 (TAK Server's cert-enrollment + * port, which differs from the 8089 connection port), overridable via + * `enrollport` / `enrollmentport` on the link for servers that colocate + * enrollment on a non-standard port. + */ + fun parseEnrollLink(uri: Uri): ImportedServerConfig? { + // `host` may be percent-encoded host:port (Uri.getQueryParameter + // already decodes %3A → ':'). Split host from the connection port. + val rawHost = uri.getQueryParameter("host")?.trim().orEmpty() + if (rawHost.isBlank()) return null + val (host, portFromHost) = splitHostPort(rawHost) + if (host.isBlank()) return null + + val username = uri.getQueryParameter("username")?.takeIf { it.isNotBlank() } + ?: return null + // ATAK uses `token`; tolerate `password` as a fallback for generators + // that reuse the connect-form param name on an enroll link. + val token = (uri.getQueryParameter("token") ?: uri.getQueryParameter("password")) + ?.takeIf { it.isNotEmpty() } ?: return null + + // Connection port: prefer host:port suffix, then an explicit ?port=, + // else the TAK streaming default 8089. + val connectPort = portFromHost + ?: uri.getQueryParameter("port")?.toIntOrNull()?.takeIf { it in 1..65535 } + ?: 8089 + + // Enrollment port is a SEPARATE port from the connection port — TAK + // Server enrolls on 8446 by default. Overridable for non-standard rigs. + val enrollmentPort = (uri.getQueryParameter("enrollport") + ?: uri.getQueryParameter("enrollmentport")) + ?.toIntOrNull()?.takeIf { it in 1..65535 } ?: 8446 + + // Trust-all during enrollment by default so a single QR onboards both + // self-signed (self-hosted TAK Server) and publicly-trusted (ArgusTAK + // behind Let's Encrypt) endpoints. Override with trust=ca on the link. + val trustRaw = (uri.getQueryParameter("trustselfsigned") + ?: uri.getQueryParameter("trust"))?.lowercase() + val trustSelfSigned = trustRaw !in setOf("false", "ca", "system", "0", "no") + + val name = uri.getQueryParameter("name")?.takeIf { it.isNotBlank() } ?: host + + return ImportedServerConfig( + name = name, + host = host, + port = connectPort, + useTLS = true, // enrollment is always mTLS + username = username, + password = token, // token = the enrollment Basic-auth secret + enrollmentPort = enrollmentPort, + trustSelfSigned = trustSelfSigned, + ) + } + + /** + * Split a `host` or `host:port` string into (host, port?). Tolerates a + * trailing colon with no port and a non-numeric port (treated as no + * port). Bracketed IPv6 literals (`[::1]:8089`) are handled so the + * colons inside the address aren't mistaken for the port separator. + * + * `internal` so it can be unit-tested without Robolectric — this is the + * load-bearing decode of the URL-encoded `host=argustak.com%3A8089` form. + */ + internal fun splitHostPort(value: String): Pair { + val v = value.trim() + if (v.startsWith("[")) { + // IPv6: [addr]:port + val close = v.indexOf(']') + if (close < 0) return v to null + val addr = v.substring(1, close) + val rest = v.substring(close + 1) + val port = rest.removePrefix(":").toIntOrNull()?.takeIf { it in 1..65535 } + return addr to port + } + val idx = v.lastIndexOf(':') + if (idx <= 0 || idx == v.length - 1) return v.removeSuffix(":") to null + val maybePort = v.substring(idx + 1).toIntOrNull()?.takeIf { it in 1..65535 } + // A malformed/out-of-range port after the colon must not poison the + // hostname (DNS would fail on "host:abc") — strip to the host portion + // and report no port so the caller falls back to its default. + return v.substring(0, idx) to maybePort + } + /** * Parse a server-config URI. Returns null if the URI doesn't carry * a usable host or port — the caller should toast a friendly error diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/navigation/AppNav.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/navigation/AppNav.kt index 3e64a7c..e97b62a 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/navigation/AppNav.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/navigation/AppNav.kt @@ -191,6 +191,7 @@ fun AppNav() { ServersScreen( onAdd = { nav.navigate("servers/add") }, onQuickConnect = { nav.navigate("servers/enroll") }, + onScanQr = { nav.navigate("servers/scan") }, ) } composable("servers/add") { @@ -199,6 +200,11 @@ fun AppNav() { composable("servers/enroll") { EnrollServerScreen(onDone = { nav.popBackStack() }) } + composable("servers/scan") { + soy.engindearing.omnitak.mobile.ui.screens.ServerEnrollScanRoute( + onDone = { nav.popBackStack() }, + ) + } composable("uas") { UASScreen(onDone = { nav.popBackStack() }) } diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/ServerQrScanScreen.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/ServerQrScanScreen.kt new file mode 100644 index 0000000..3589c0f --- /dev/null +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/ServerQrScanScreen.kt @@ -0,0 +1,382 @@ +package soy.engindearing.omnitak.mobile.ui.screens + +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import android.widget.Toast +import androidx.compose.runtime.rememberCoroutineScope +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import soy.engindearing.omnitak.mobile.OmniTAKApp +import soy.engindearing.omnitak.mobile.data.CSREnrollmentService +import soy.engindearing.omnitak.mobile.data.ConnectionProtocol +import soy.engindearing.omnitak.mobile.data.DeepLinkImport +import soy.engindearing.omnitak.mobile.data.ImportedServerConfig +import soy.engindearing.omnitak.mobile.data.TAKServer +import soy.engindearing.omnitak.mobile.ui.theme.TacticalAccent +import soy.engindearing.omnitak.mobile.ui.theme.TacticalBackground +import java.util.concurrent.Executors + +private const val TAG = "ServerQrScan" + +/** + * #100 — full-screen camera QR scanner for server onboarding. + * + * Reuses the exact CameraX + MLKit pipeline proven by the config-profile + * scanner in [ProfilesScreen.QrScannerDialog], lifted here as a standalone + * screen so the server-enrollment flow doesn't depend on a profile-only, + * `private` dialog. The full-screen treatment keeps the sacred map intact — + * this is its own route, never a panel compressing the map. + * + * Decoding is delegated to [accept]: the caller decides which QR shapes are + * valid (e.g. a `tak://…/enroll` enrollment link) so this stays generic. On + * the first accepted QR we fire [onScanned] once and stop analyzing. + * + * @param accept returns true if the decoded [Uri] is one this scan flow + * should consume. Keeps the camera running on unrelated QR codes. + * @param onScanned invoked once with the first accepted [Uri]. + * @param onDismiss user backed out without scanning. + */ +@OptIn(ExperimentalGetImage::class) +@kotlin.OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServerQrScanScreen( + accept: (Uri) -> Boolean, + onScanned: (Uri) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // Camera permission gate — request on first composition if not already held. + var cameraGranted by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED, + ) + } + val permLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> cameraGranted = granted } + LaunchedEffect(Unit) { + if (!cameraGranted) permLauncher.launch(Manifest.permission.CAMERA) + } + + Scaffold( + containerColor = TacticalBackground, + topBar = { + TopAppBar( + title = { Text("Scan enrollment QR", color = MaterialTheme.colorScheme.onBackground) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + Icons.Filled.Close, + contentDescription = "Close scanner", + tint = MaterialTheme.colorScheme.onBackground, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = TacticalBackground), + ) + }, + ) { inner: PaddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(inner), + ) { + if (!cameraGranted) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Camera permission is required to scan an enrollment QR code.", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { permLauncher.launch(Manifest.permission.CAMERA) }, + colors = ButtonDefaults.buttonColors( + containerColor = TacticalAccent, + contentColor = TacticalBackground, + ), + ) { Text("Grant camera access") } + } + return@Box + } + + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + val executor = remember { Executors.newSingleThreadExecutor() } + var scanned by remember { mutableStateOf(false) } + val barcodeScanner = remember { BarcodeScanning.getClient() } + + DisposableEffect(Unit) { + onDispose { + barcodeScanner.close() + executor.shutdown() + } + } + + // Full-bleed camera preview. + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx) + cameraProviderFuture.addListener({ + val provider = cameraProviderFuture.get() + val preview = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + analysis.setAnalyzer(executor) { imageProxy -> + if (scanned) { imageProxy.close(); return@setAnalyzer } + val mediaImage = imageProxy.image + if (mediaImage != null) { + val inputImage = InputImage.fromMediaImage( + mediaImage, + imageProxy.imageInfo.rotationDegrees, + ) + barcodeScanner.process(inputImage) + .addOnSuccessListener { barcodes -> + val qr = barcodes.firstOrNull { it.format == Barcode.FORMAT_QR_CODE } + val raw = qr?.rawValue + if (!raw.isNullOrBlank() && !scanned) { + val uri = runCatching { Uri.parse(raw) }.getOrNull() + if (uri != null && accept(uri)) { + scanned = true + onScanned(uri) + } + } + } + .addOnCompleteListener { imageProxy.close() } + } else { + imageProxy.close() + } + } + runCatching { + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + }.onFailure { Log.e(TAG, "CameraX bind failed", it) } + }, ContextCompat.getMainExecutor(ctx)) + previewView + }, + modifier = Modifier.fillMaxSize(), + ) + + // Reticle + hint overlay. + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(240.dp) + .clip(RoundedCornerShape(16.dp)) + .border(2.dp, TacticalAccent, RoundedCornerShape(16.dp)), + ) + Spacer(Modifier.height(20.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(TacticalBackground.copy(alpha = 0.7f)) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + "Point at a TAK / ATAK enrollment QR code", + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +/** + * #100 — drop-in route that pairs [ServerQrScanScreen] with the server + * onboarding flow. Accepts the standard ATAK / TAK Server / ArgusTAK + * enrollment QR (`tak://…/enroll`) plus our existing connect-form links, then: + * + * - enrollment link → CSR-enroll a client cert from the server's + * `/Marti/api/tls/signClient/v2` endpoint and add (auto-connect) the server, + * mirroring EnrollServerScreen's success path, + * - plain connect link with creds → same CSR auto-enroll, + * - plain connect link without creds → cert-less add. + * + * This is the reliable in-app on-ramp that does not depend on the OS camera + * app routing the `tak://` scheme back to us. Pops back via [onDone] once the + * scan is consumed (enrollment itself continues async with a toast). + */ +@Composable +fun ServerEnrollScanRoute(onDone: () -> Unit) { + val context = LocalContext.current + val app = context.applicationContext as OmniTAKApp + val scope = rememberCoroutineScope() + + ServerQrScanScreen( + accept = { uri -> + DeepLinkImport.isEnrollLink(uri) || DeepLinkImport.isServerConfig(uri) + }, + onScanned = { uri -> + val enrollCfg = if (DeepLinkImport.isEnrollLink(uri)) { + DeepLinkImport.parseEnrollLink(uri) + } else { + DeepLinkImport.parseServerConfig(uri) + } + if (enrollCfg == null) { + Toast.makeText( + context, + "QR missing host / credentials", + Toast.LENGTH_LONG, + ).show() + onDone() + return@ServerQrScanScreen + } + if (enrollCfg.needsEnrollment) { + enrollAndAdd(context, app, scope, enrollCfg) + } else { + app.serverManager.addServer(DeepLinkImport.toServer(enrollCfg)) + Toast.makeText( + context, + "Added server: ${enrollCfg.name} (${enrollCfg.host}:${enrollCfg.port})", + Toast.LENGTH_LONG, + ).show() + } + onDone() + }, + onDismiss = onDone, + ) +} + +/** + * Shared CSR enroll-and-add used by the in-app scanner. Same call shape as + * MainActivity's deep-link enroll path and EnrollServerScreen — request a + * signed client cert, then add (auto-connect) the server with the enrolled + * .p12 + pinned CA wired up. Enrollment runs off the main thread; the user + * gets toasts on start / success / failure. + */ +private fun enrollAndAdd( + context: android.content.Context, + app: OmniTAKApp, + scope: kotlinx.coroutines.CoroutineScope, + cfg: ImportedServerConfig, +) { + Toast.makeText(context, "Enrolling with ${cfg.host}…", Toast.LENGTH_SHORT).show() + scope.launch { + val result = runCatching { + withContext(Dispatchers.IO) { + CSREnrollmentService(app.certVault).enroll( + CSREnrollmentService.Config( + host = cfg.host, + enrollmentPort = cfg.enrollmentPort, + username = cfg.username!!, + password = cfg.password!!, + trustSelfSigned = cfg.trustSelfSigned, + ), + ) + } + } + result.onSuccess { enrolled -> + app.serverManager.addServer( + TAKServer( + name = cfg.name, + host = cfg.host, + port = cfg.port, + protocol = ConnectionProtocol.TLS.wire, + useTLS = true, + username = cfg.username, + // password is the login/enrollment credential, not the .p12 passphrase + password = null, + certificateName = enrolled.certificateName, + certificatePassword = enrolled.certificatePassword, + caCertificateName = enrolled.caCertificateName, + ), + ) + Toast.makeText( + context, + "Enrolled & connected: ${cfg.name}", + Toast.LENGTH_LONG, + ).show() + } + result.onFailure { e -> + Toast.makeText( + context, + "Enrollment failed: ${e.message ?: e.javaClass.simpleName}", + Toast.LENGTH_LONG, + ).show() + } + } +} diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/ServersScreen.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/ServersScreen.kt index 1949326..d7703c3 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/ServersScreen.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/ServersScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.ExperimentalMaterial3Api @@ -62,7 +63,11 @@ import soy.engindearing.omnitak.mobile.ui.theme.TacticalSurface @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ServersScreen(onAdd: () -> Unit, onQuickConnect: () -> Unit = {}) { +fun ServersScreen( + onAdd: () -> Unit, + onQuickConnect: () -> Unit = {}, + onScanQr: () -> Unit = {}, +) { val app = LocalContext.current.applicationContext as OmniTAKApp val manager = app.serverManager val servers by manager.servers.collectAsState() @@ -86,6 +91,18 @@ fun ServersScreen(onAdd: () -> Unit, onQuickConnect: () -> Unit = {}) { Text("TAK Servers", color = MaterialTheme.colorScheme.onBackground) }, actions = { + // #100 — scan a standard TAK / ATAK enrollment QR + // (tak://…/enroll) to CSR-enroll a cert and connect. The + // reliable in-app on-ramp that doesn't depend on the OS + // camera app routing the tak:// scheme back to us. + IconButton(onClick = onScanQr) { + Icon( + Icons.Filled.QrCodeScanner, + contentDescription = "Scan enrollment QR", + tint = TacticalAccent, + modifier = Modifier.size(20.dp), + ) + } // Quick Connect — request a client cert from the server's // enrollment endpoint instead of importing a pre-issued .p12. // BUG-C (closed-test, May 2026) — the icon-only bolt was @@ -195,8 +212,9 @@ private fun EmptyServers(modifier: Modifier = Modifier) { ) Spacer(Modifier.height(8.dp)) Text( - "Tap + to enter a server manually, or Quick Connect (⚡) to " + - "auto-enroll a certificate from a TAK Server's enrollment " + + "Scan (▣) a TAK/ATAK enrollment QR to enroll and connect in one " + + "tap, tap + to enter a server manually, or Quick Connect (⚡) " + + "to auto-enroll a certificate from a TAK Server's enrollment " + "endpoint.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), diff --git a/app/src/test/kotlin/soy/engindearing/omnitak/mobile/data/DeepLinkEnrollTest.kt b/app/src/test/kotlin/soy/engindearing/omnitak/mobile/data/DeepLinkEnrollTest.kt new file mode 100644 index 0000000..aabb73c --- /dev/null +++ b/app/src/test/kotlin/soy/engindearing/omnitak/mobile/data/DeepLinkEnrollTest.kt @@ -0,0 +1,155 @@ +package soy.engindearing.omnitak.mobile.data + +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +/** + * Unit tests for the #100 enrollment-link parsing in [DeepLinkImport]: + * the standard ATAK / TAK Server / ArgusTAK QR + * `tak://com.atakmap.app/enroll?host=[:port]&username=&token=`. + * + * The host:port split (the URL-encoded `host=argustak.com%3A8089` decode) is + * pure-Kotlin and tested directly. The [Uri]-based detector/parser tests are + * guarded with `assumeTrue` because `android.net.Uri.parse` returns stubs + * under `unitTests.isReturnDefaultValues = true` (no Robolectric on the + * classpath) — same convention as ConfigProfileTest. + */ +class DeepLinkEnrollTest { + + // ── splitHostPort (pure JVM, always runs) ───────────────────────────── + + @Test + fun splitHostPort_hostAndPort() { + val (host, port) = DeepLinkImport.splitHostPort("argustak.com:8089") + assertEquals("argustak.com", host) + assertEquals(8089, port) + } + + @Test + fun splitHostPort_bareHostHasNoPort() { + val (host, port) = DeepLinkImport.splitHostPort("argustak.com") + assertEquals("argustak.com", host) + assertNull(port) + } + + @Test + fun splitHostPort_trailingColonHasNoPort() { + val (host, port) = DeepLinkImport.splitHostPort("tak.example.com:") + assertEquals("tak.example.com", host) + assertNull(port) + } + + @Test + fun splitHostPort_nonNumericPortIsIgnored() { + val (host, port) = DeepLinkImport.splitHostPort("tak.example.com:abc") + assertEquals("tak.example.com", host) + assertNull(port) + } + + @Test + fun splitHostPort_outOfRangePortIsIgnored() { + val (host, port) = DeepLinkImport.splitHostPort("tak.example.com:99999") + assertEquals("tak.example.com", host) + assertNull(port) + } + + @Test + fun splitHostPort_ipv4WithPort() { + val (host, port) = DeepLinkImport.splitHostPort("10.0.0.5:8089") + assertEquals("10.0.0.5", host) + assertEquals(8089, port) + } + + @Test + fun splitHostPort_ipv6Bracketed() { + val (host, port) = DeepLinkImport.splitHostPort("[2001:db8::1]:8089") + assertEquals("2001:db8::1", host) + assertEquals(8089, port) + } + + @Test + fun splitHostPort_ipv6BracketedNoPort() { + val (host, port) = DeepLinkImport.splitHostPort("[2001:db8::1]") + assertEquals("2001:db8::1", host) + assertNull(port) + } + + // ── isEnrollLink / parseEnrollLink (need Uri.parse — skipped on stub) ── + + private fun parsed(uriString: String): Uri? = + runCatching { Uri.parse(uriString) }.getOrNull() + ?.takeIf { it.scheme != null } + + @Test + fun isEnrollLink_acceptsStandardTakEnrollUri() { + val uri = parsed( + "tak://com.atakmap.app/enroll?host=argustak.com%3A8089&username=u&token=t", + ) + assumeTrue("Uri.parse stubbed on JVM", uri != null) + assertTrue(DeepLinkImport.isEnrollLink(uri)) + } + + @Test + fun isEnrollLink_rejectsConnectForm() { + val uri = parsed("atak://com.atakmap.app/connect?host=tak.example.com&port=8089") + assumeTrue("Uri.parse stubbed on JVM", uri != null) + assertFalse(DeepLinkImport.isEnrollLink(uri)) + } + + @Test + fun isEnrollLink_rejectsHttpScheme() { + val uri = parsed("https://evil.example.com/enroll?host=tak.example.com&username=u&token=t") + assumeTrue("Uri.parse stubbed on JVM", uri != null) + assertFalse(DeepLinkImport.isEnrollLink(uri)) + } + + @Test + fun parseEnrollLink_extractsAllFieldsAndDefaults() { + val uri = parsed( + "tak://com.atakmap.app/enroll?host=argustak.com%3A8089&username=alpha&token=secret", + ) + assumeTrue("Uri.parse stubbed on JVM", uri != null) + val cfg = DeepLinkImport.parseEnrollLink(uri!!) + assertTrue(cfg != null) + cfg!! + assertEquals("argustak.com", cfg.host) + assertEquals(8089, cfg.port) // connection port from host:port + assertEquals("alpha", cfg.username) + assertEquals("secret", cfg.password) // token mapped to enroll secret + assertEquals(8446, cfg.enrollmentPort) // separate default enroll port + assertTrue(cfg.useTLS) + assertTrue(cfg.needsEnrollment) + } + + @Test + fun parseEnrollLink_bareHostDefaultsConnectPort8089() { + val uri = parsed("tak://com.atakmap.app/enroll?host=tak.example.com&username=u&token=t") + assumeTrue("Uri.parse stubbed on JVM", uri != null) + val cfg = DeepLinkImport.parseEnrollLink(uri!!) + assertTrue(cfg != null) + assertEquals(8089, cfg!!.port) + } + + @Test + fun parseEnrollLink_overridableEnrollPort() { + val uri = parsed( + "tak://com.atakmap.app/enroll?host=tak.example.com%3A8089&username=u&token=t&enrollport=8443", + ) + assumeTrue("Uri.parse stubbed on JVM", uri != null) + val cfg = DeepLinkImport.parseEnrollLink(uri!!) + assertTrue(cfg != null) + assertEquals(8443, cfg!!.enrollmentPort) + } + + @Test + fun parseEnrollLink_missingTokenReturnsNull() { + val uri = parsed("tak://com.atakmap.app/enroll?host=tak.example.com%3A8089&username=u") + assumeTrue("Uri.parse stubbed on JVM", uri != null) + assertNull(DeepLinkImport.parseEnrollLink(uri!!)) + } +}