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!!))
+ }
+}