feat: add customer branding and live connection diagnostics

This commit is contained in:
james.maes committed 2026-10-29 15:18:36 -05:00
1 parent dfdbb74d21
commit a5621c586a
16 files changed
+455 -20

No files matched your search

+6
View File
@@ -38,6 +38,12 @@ Create a profile associated with `io.qrun.mobile.scanner`, activity `*`:
The receiver is active only while a scanner widget is visible and its activity is resumed. It reads `com.symbol.datawedge.data_string`, `label_type`, and `source`. Multi-decode payloads are rejected. Symbology/source are client-reported metadata, not proof of scanner hardware identity. See [Zebra Intent output](https://techdocs.zebra.com/datawedge/latest/guide/output/intent/).
## Customer branding and diagnostics
The shared launcher name/icon remain QQQ Mobile. On connecting to a server, the host reads public `GET /mobile/v1/branding` (`displayName`, `primaryColor`, optional `logoUrl`). The name and Material theme apply to login, device approval, and native app screens. Names are limited to 80 printable characters and colors to `#RRGGBB`; malformed values fall back to QQQ defaults. Button foregrounds choose readable black/white contrast. Optional logos must use HTTPS, are fetched without session/device headers or cookies, and cannot prevent login if unavailable. Responses are size-limited, redirects are disabled, and branding is reset when changing servers. Reconnect to pick up server branding changes; no APK rebuild is needed.
The persistent footer shows **Checking**, **Connected**, or **Disconnected** with the last check time. It checks `GET /mobile/v1/status` every five seconds while foregrounded and on network changes; `Connected` means a valid `{ "status": "ok" }` response, not sign-in or device approval. Tap the footer for **Copy**, **Clear**, and **Close** diagnostics. The in-memory log holds the latest 100 fixed event codes with timestamps, HTTP status, and latency for branding, connection checks, native QQQ calls (including login/process requests), and device enrollment/status requests. A transport failure has no HTTP status; an HTTP response retains its status, including 401/403/500 errors. The log contains no credentials, scan values, request paths, queries, raw response text, or exception messages. Changing servers clears the displayed diagnostics. Monitoring never queues or retries a scan.
## Online-only behavior
There is no offline queue. One submitted scan is retained in memory until its `acknowledgedEventId` is returned. If a response is uncertain, **Retry same request** preserves its event ID and original timestamp; the server deduplicates it. Further scans are blocked until the pending request is resolved. Sign-out/reset clears the pending request; app termination loses in-memory state. Repeated physical scans deliberately receive distinct event IDs.
+11 -1
View File
@@ -1,11 +1,21 @@
# Session state
2026-10-29 verification: all 26 JVM tests passed, lint reported zero errors/six warnings, and the debug APK assembled successfully. Runtime diagnostics now include sanitized native API and device-request outcomes, in addition to branding and heartbeat events. Android 15 system-bar insets were corrected after emulator inspection. The shared mobile library now declares its connectivity permission for all consumers. Login copy uses a neutral title beneath the customer branding. CI APK artifacts include their own `SHA256SUMS`; local builds can be hashed with `Get-FileHash`.
Repository implementation is published for review in [PR #8](https://github.com/QRun-IO/qqq-android/pull/8); it has not been merged or released. The initial scanner commit passed hosted CircleCI and scanner GitHub Actions verification. The companion qbit is published at https://github.com/QRun-IO/qbit-mobile-scanners, with hosted server/CodeQL/demo checks passing. An API35 emulator is available locally, with server `http://127.0.0.1:8080/` forwarded to host demo port57832. Branding, native login screen and diagnostics were visually verified without entering user credentials. Final-APK native instrumentation and connection-loss recovery are being recorded separately.
Earlier implementation records follow; the publication and emulator statements above supersede their original pending status.
Runtime branding and diagnostics added: public server metadata supplies display name, primary color, and an optional HTTPS logo; malformed/missing metadata uses QQQ defaults and server changes reset presentation. A persistent foreground connection footer checks the public status endpoint, with bounded credential-free diagnostics Copy/Clear. Shared top-bar colors now use the matching Material foreground role and its connectivity callback is disposed correctly. Branding/status protocol, isolation, malformed input, response redaction, and log bounds have JVM regression tests. Validation passed: full 23 JVM tests (core12, mobileapp4, scanner7), debug APK assembly, and lint with zero errors / six warnings. The new APK SHA256 is F388AF488688DE87811FE32C94D07601DC3DD61ADAC139069ADCFA627E21D01C. Updated emulator UI acceptance is pending.
Follow-up: diagnostics now also collect fixed native QQQ/device request outcomes through an optional, exception-isolated observer (status and latency only). Added regression cases for HTTP errors, transport failure, observer exceptions, and category formatting. Emulator confirmed F388 branding/login/Connected footer/diagnostics; observed Android15 system-bar overlap corrected with safe-drawing insets. Final verification for these follow-ups is pending.
2026-10-29: Implementing QQQ Mobile scanner host and native host extensions. First target is Zebra TC22, online only. Backend companion is `qbit-mobile-scanners`; metadata-defined apps/processes remain the native execution model. Device approval is mandatory. Local demo uses QQQ TABLE_BASED auth on QQQ4.1.0-RC.20.
Implemented server selection, per-server enrollment credentials, approval gate, SCANNER widget DataWedge input, explicit same-event retry, native password login, host extension points, and removal of session/token logging from touched authentication paths. No commit or push.
Validation passed: 19 JVM tests (core12, mobileapp4, scanner3), debug APK assembly, APK signature verification, and lint with zero errors. Five lint warnings remain: three inherited Navigation custom-check compatibility warnings and two dependency/catalog notices. Real sanitized demo HTTP metadata/process/init fixtures deserialize through the native DTOs. Server logout, HttpOnly-cookie login, and per-origin header isolation have regression tests. Physical TC22 acceptance remains required.
Final debug APK SHA256: B3E160CE3279C369CEDB425AC6E777A2729CE8A2E430E7D6F46D35C6376F8214.
Previous pre-branding debug APK SHA256: B3E160CE3279C369CEDB425AC6E777A2729CE8A2E430E7D6F46D35C6376F8214.
Repository readiness added: pinned GitHub scanner tests/lint/APK artifacts and optional emulator workflow; CODEOWNERS, contribution/security/conduct guidance, issue/PR templates, Dependabot, changelog, editor/line-ending policy, and scoped agent instructions. `actionlint`, `shellcheck`, `shfmt`, and `git diff --check` passed. Upstream main `63fca89` was fast-forwarded and all implementation files restored without conflicts; the managed CircleCI Android profile and QRun-IO Maven registry updates are preserved. The full Gradle verification command passed again after reconciliation (16 executed tasks, remaining unchanged tasks up to date). Remote CI and emulator acceptance have not yet been recorded here.
+7 -3
View File
@@ -1,11 +1,15 @@
# TODO
- [x] Complete Gradle unit tests, debug APK, signature, and lint verification (19 tests; zero lint errors).
- [x] Add runtime server branding and a foreground connection footer with bounded sanitized diagnostics.
- [ ] Verify customer logo/theme and connection transitions on the emulator and physical TC22.
- [x] Complete Gradle unit tests, debug APK, signature, and lint verification (26 tests; zero lint errors).
- [ ] Run physical TC22 acceptance: DataWedge trigger, rotation/background lifecycle, duplicate retry, revoked device, bad credentials, sign-out and re-login.
- [ ] Verify release APK signing/distribution separately; current deliverable is a debug APK.
- [ ] Reconcile historical source license headers with the root license before publication.
- [ ] Reconcile historical source license headers with the root license before production distribution.
- [ ] Configure/test production identity-provider integration if selected; version one deliberately supports TABLE_BASED sign-in only and rejects other providers with a clear message.
Offline queue/background synchronization is deliberately outside version one.
- [ ] Verify remote Scanner Android workflow after publication and run the optional emulator lane.
- [x] Verify remote Scanner Android workflow after publication.
- [ ] Run the optional hosted emulator lane after its workflow is merged to the default branch.
- [ ] Run the native sample instrumentation suite and full interactive scanner demo on an emulator.
@@ -46,7 +46,7 @@ import java.util.concurrent.ConcurrentHashMap
/***************************************************************************
**
***************************************************************************/
class NetworkQQQRepository(private var baseUrl: String, private val additionalHeaders: (String) -> Map<String, String> = { emptyMap() }) : QQQRepository
class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val additionalHeaders: (String) -> Map<String, String> = { emptyMap() }) : QQQRepository
{
private val sessionUUIDs = ConcurrentHashMap<String, String>()
private val retroJson = Json { ignoreUnknownKeys = true }
@@ -82,6 +82,7 @@ class NetworkQQQRepository(private var baseUrl: String, private val additionalHe
OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.addInterceptor(RequestOutcomeInterceptor { requestObserver(it) })
.addInterceptor { chain ->
val request = chain.request().newBuilder()
sessionUUIDs[baseUrl]?.takeIf { it.isNotBlank() }?.let { request.header("Cookie", "sessionUUID=$it") }
@@ -0,0 +1,31 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.controllers
import java.io.IOException
import okhttp3.Interceptor
import okhttp3.Response
/** Deliberately excludes URLs, headers, bodies, and exception messages. Null status means transport failure. */
data class RequestOutcome(val httpStatus: Int?, val elapsedMillis: Long)
/** Optional diagnostics must never change request behavior, even if an observer throws. */
class RequestOutcomeInterceptor(private val observer: (RequestOutcome) -> Unit) : Interceptor
{
override fun intercept(chain: Interceptor.Chain): Response
{
val started = System.nanoTime()
fun report(status: Int?)
{
runCatching { observer(RequestOutcome(status, (System.nanoTime() - started) / 1_000_000)) }
}
try
{
return chain.proceed(chain.request()).also { report(it.code) }
}
catch(exception: IOException)
{
report(null)
throw exception
}
}
}
@@ -7,11 +7,49 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Test
/** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped. */
class NetworkQQQRepositoryTest
{
@Test
fun reportsOnlyStatusAndTimingAndObserverFailuresCannotBreakRequests() = runBlocking()
{
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody("{}"))
server.enqueue(MockResponse().setResponseCode(503).setBody("private-response-barcode"))
val outcomes = mutableListOf<RequestOutcome>()
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin, requestObserver = { outcomes.add(it); error("observer failed") })
repository.setEnvironment(Environment("test", origin))
repository.setSessionUUID("private-cookie")
repository.getMetaData("private-query", "1", "test", "1")
assertTrue(runCatching { repository.getMetaData("test", "1", "test", "1") }.isFailure)
assertEquals(listOf(200, 503), outcomes.map { it.httpStatus })
assertTrue(outcomes.all { it.elapsedMillis >= 0 })
assertFalse(outcomes.toString().contains("private"))
assertFalse(outcomes.toString().contains("http:"))
}
}
@Test
fun transportFailureReportsNullStatusWithoutExceptionMessage() = runBlocking()
{
val server = MockWebServer()
server.start()
val origin = server.url("/").toString()
server.shutdown()
val outcomes = mutableListOf<RequestOutcome>()
val repository = NetworkQQQRepository(origin, requestObserver = { outcomes.add(it) })
repository.setEnvironment(Environment("test", origin))
assertTrue(runCatching { repository.getMetaData("test", "1", "test", "1") }.isFailure)
assertEquals(1, outcomes.size)
assertNull(outcomes.single().httpStatus)
assertFalse(outcomes.toString().contains("localhost"))
}
@Test
fun logoutRevokesServerSessionBeforeDroppingTheCookie() = runBlocking()
{
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
@@ -28,7 +28,7 @@ fun QPasswordLogin(viewModel: QViewModel, modifier: Modifier = Modifier)
val busy = submitted && viewModel.manageSessionLoadState is LoadState.Loading
Column(modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
Text("Sign in to QQQ")
Text("Sign in")
OutlinedTextField(username, { username = it }, label = { Text("Username") }, singleLine = true, enabled = !busy)
OutlinedTextField(password, { password = it }, label = { Text("Password") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(), enabled = !busy)
@@ -38,6 +38,7 @@ 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.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -84,12 +85,16 @@ fun QTopAppBar(qViewModel: QViewModel, defaultTitle: String = "A QQQ Android App
}
val connectivityManager = getSystemService(LocalContext.current, ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.requestNetwork(networkRequest, networkCallback)
DisposableEffect(connectivityManager)
{
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
onDispose { connectivityManager.unregisterNetworkCallback(networkCallback) }
}
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.testTag("topAppBar.title")) },
navigationIcon =
+4
View File
@@ -37,6 +37,10 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.material3)
implementation(libs.androidx.ui)
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation("com.squareup.okhttp3:okhttp:4.11.0")
testImplementation(libs.junit)
testImplementation("com.squareup.okhttp3:mockwebserver:4.11.0")
}
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application android:allowBackup="false" android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:usesCleartextTraffic="false"
android:icon="@drawable/ic_scanner"
@@ -2,6 +2,8 @@
package io.qrun.mobile.scanner
import android.os.Build
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -14,9 +16,10 @@ import org.json.JSONObject
class EnrollmentConflict : IllegalStateException("Registration already exists but its credentials were not received. Start a new registration and ask the administrator to approve it.")
/** The native QQQ session also authenticates device management. */
class DeviceApi(private val server: String, private val credentials: DeviceCredentials)
class DeviceApi(private val server: String, private val credentials: DeviceCredentials, onRequestOutcome: (RequestOutcome) -> Unit = {})
{
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).build()
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).build()
/** Check enrollment on demand; approval remains a server-side decision. */
suspend fun status(session: String, displayName: String): String = withContext(Dispatchers.IO)
@@ -0,0 +1,123 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.net.ConnectivityManager
import android.net.Network
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import coil3.ImageLoader
import coil3.compose.AsyncImage
import coil3.svg.SvgDecoder
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
/** Lifetime is keyed by selected server: presentation and diagnostics cannot bleed across customers. */
@Composable
internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Composable (HostBrand, DiagnosticLog) -> Unit)
{
val runtime = remember(server) { HostRuntime(server) }
var brand by remember(server) { mutableStateOf(HostBrand()) }
var logo by remember(server) { mutableStateOf<ByteArray?>(null) }
var connection by remember(server) { mutableStateOf("Checking") }
var checkedAt by remember(server) { mutableStateOf("not checked") }
var showDiagnostics by remember(server) { mutableStateOf(false) }
var diagnosticText by remember(server) { mutableStateOf("") }
val context = LocalContext.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
val imageLoader = remember(context) { ImageLoader.Builder(context).components { add(SvgDecoder.Factory()) }.build() }
DisposableEffect(imageLoader) { onDispose { imageLoader.shutdown() } }
LaunchedEffect(runtime)
{
brand = runtime.branding()
logo = brand.logoUrl?.let { runtime.logo(it) }
diagnosticText = runtime.diagnostics.text()
}
LaunchedEffect(runtime, lifecycle)
{
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{
val changed = Channel<Unit>(Channel.CONFLATED)
val connectivity = context.getSystemService(ConnectivityManager::class.java)
val callback = object : ConnectivityManager.NetworkCallback()
{
override fun onAvailable(network: Network) { changed.trySend(Unit) }
override fun onLost(network: Network) { changed.trySend(Unit) }
}
connectivity.registerDefaultNetworkCallback(callback)
try
{
connection = "Checking"
while(isActive)
{
connection = if(runtime.connected()) "Connected" else "Disconnected"
checkedAt = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
diagnosticText = runtime.diagnostics.text()
withTimeoutOrNull(5000) { changed.receive() }
}
}
finally { connectivity.unregisterNetworkCallback(callback); changed.close() }
}
}
val primary = Color(0xFF000000 or brand.primaryColor.drop(1).toLong(16))
val onPrimary = if(useBlackOnPrimary(brand.primaryColor)) Color.Black else Color.White
MaterialTheme(colorScheme = lightColorScheme(primary = primary, onPrimary = onPrimary,
primaryContainer = lerp(primary, Color.White, 0.85f), onPrimaryContainer = Color.Black))
{
Surface()
{
Column(Modifier.fillMaxSize())
{
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
{
logo?.let { AsyncImage(model = it, imageLoader = imageLoader, contentDescription = "${brand.displayName} logo", modifier = Modifier.size(40.dp)) }
Text(brand.displayName, Modifier.weight(1f), style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
TextButton(onClick = onChangeServer) { Text("Change server") }
}
Box(Modifier.weight(1f)) { content(brand, runtime.diagnostics) }
HorizontalDivider()
TextButton(onClick = { diagnosticText = runtime.diagnostics.text(); showDiagnostics = true }, modifier = Modifier.fillMaxWidth())
{
Text("$connection · $checkedAt · Diagnostics", color = MaterialTheme.colorScheme.onSurface)
}
}
}
if(showDiagnostics)
{
val clipboard = LocalClipboardManager.current
AlertDialog(onDismissRequest = { showDiagnostics = false }, title = { Text("Connection diagnostics") },
text = {
Column()
{
Text("Connection checks server reachability only. Sign-in and device approval are checked separately.")
Text(diagnosticText.ifBlank { "No events." }, Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()))
}
},
confirmButton = { TextButton(onClick = { clipboard.setText(AnnotatedString(diagnosticText)) }) { Text("Copy") } },
dismissButton = {
Row()
{
TextButton(onClick = { runtime.diagnostics.clear(); diagnosticText = "" }) { Text("Clear") }
TextButton(onClick = { showDiagnostics = false }) { Text("Close") }
}
})
}
}
}
@@ -0,0 +1,111 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import java.net.URI
import java.time.Instant
import java.util.ArrayDeque
import java.util.concurrent.TimeUnit
import kotlin.math.pow
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
/** Public presentation data; never mixed with a session or device credential. */
internal data class HostBrand(val displayName: String = "QQQ Mobile Scanners", val primaryColor: String = "#1565C0", val logoUrl: String? = null)
{
companion object
{
fun parse(payload: String): HostBrand
{
val fields = runCatching { Json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: return HostBrand()
fun string(name: String): String? = (fields[name] as? JsonPrimitive)?.takeIf { it.isString }?.content
val name = string("displayName")?.trim()?.takeIf { it.length in 1..80 && it.none { c -> c.isISOControl() || c == '<' || c == '>' } }
val color = string("primaryColor")?.takeIf { Regex("#[0-9a-fA-F]{6}").matches(it) }?.uppercase()
val logo = string("logoUrl")?.takeIf { isSafeLogoUrl(it) }
return HostBrand(name ?: HostBrand().displayName, color ?: HostBrand().primaryColor, logo)
}
}
}
internal fun isSafeLogoUrl(value: String): Boolean = runCatching {
val uri = URI(value)
value.length <= 2048 && uri.scheme == "https" && uri.host != null && uri.userInfo == null && uri.fragment == null
}.getOrDefault(false)
/** Choose the higher WCAG contrast of black/white against the supplied opaque color. */
internal fun useBlackOnPrimary(hex: String): Boolean
{
val channels = listOf(1, 3, 5).map { hex.substring(it, it + 2).toInt(16) / 255.0 }
.map { if(it <= 0.04045) it / 12.92 else ((it + 0.055) / 1.055).pow(2.4) }
val luminance = channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722
return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05)
}
internal enum class DiagnosticCode { BRAND_LOADED, BRAND_DEFAULT, LOGO_LOADED, LOGO_UNAVAILABLE, CONNECTION_OK, CONNECTION_FAILED, NATIVE_RESPONSE, NATIVE_TRANSPORT_FAILED, DEVICE_RESPONSE, DEVICE_TRANSPORT_FAILED }
internal data class DiagnosticEvent(val time: Instant, val code: DiagnosticCode, val httpStatus: Int?, val elapsedMillis: Long)
/** No free-form messages: raw responses, URLs, credentials, and barcode data cannot enter this log. */
internal class DiagnosticLog(private val capacity: Int = 100)
{
private val events = ArrayDeque<DiagnosticEvent>()
@Synchronized fun record(code: DiagnosticCode, status: Int?, elapsedMillis: Long)
{
events.addLast(DiagnosticEvent(Instant.now(), code, status, elapsedMillis.coerceAtLeast(0)))
while(events.size > capacity) events.removeFirst()
}
@Synchronized fun text(): String = events.joinToString("\n") { "${it.time} ${it.code} HTTP=${it.httpStatus ?: "none"} ${it.elapsedMillis}ms" }
@Synchronized fun clear() { events.clear() }
fun nativeRequest(outcome: RequestOutcome) = record(if(outcome.httpStatus == null) DiagnosticCode.NATIVE_TRANSPORT_FAILED else DiagnosticCode.NATIVE_RESPONSE, outcome.httpStatus, outcome.elapsedMillis)
fun deviceRequest(outcome: RequestOutcome) = record(if(outcome.httpStatus == null) DiagnosticCode.DEVICE_TRANSPORT_FAILED else DiagnosticCode.DEVICE_RESPONSE, outcome.httpStatus, outcome.elapsedMillis)
}
/** Dedicated unauthenticated client with no cookie jar, redirects, or disk cache. */
internal class HostRuntime(private val server: String, val diagnostics: DiagnosticLog = DiagnosticLog())
{
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.callTimeout(5, TimeUnit.SECONDS).build()
private suspend fun fetch(url: String, limit: Long, success: DiagnosticCode, failure: DiagnosticCode, validate: (ByteArray) -> Boolean = { true }): ByteArray? = withContext(Dispatchers.IO)
{
val start = System.nanoTime()
var status: Int? = null
try
{
client.newCall(Request.Builder().url(url).header("Cache-Control", "no-store").build()).execute().use { response ->
status = response.code
val body = response.body
if(!response.isSuccessful || body == null || body.contentLength() > limit) error("Unavailable")
val source = body.source()
source.request(limit + 1)
if(source.buffer.size > limit) error("Oversized")
val bytes = source.readByteArray()
if(!validate(bytes)) error("Unexpected response")
diagnostics.record(success, status, (System.nanoTime() - start) / 1_000_000)
bytes
}
}
catch(exception: CancellationException) { throw exception }
catch(exception: Exception)
{
diagnostics.record(failure, status, (System.nanoTime() - start) / 1_000_000)
null
}
}
suspend fun branding(): HostBrand = fetch(server + "mobile/v1/branding", 8192, DiagnosticCode.BRAND_LOADED, DiagnosticCode.BRAND_DEFAULT)
?.toString(Charsets.UTF_8)?.let { HostBrand.parse(it) } ?: HostBrand()
suspend fun logo(url: String): ByteArray? = if(isSafeLogoUrl(url)) fetch(url, 1_048_576, DiagnosticCode.LOGO_LOADED, DiagnosticCode.LOGO_UNAVAILABLE) else null
suspend fun connected(): Boolean = fetch(server + "mobile/v1/status", 1024, DiagnosticCode.CONNECTION_OK, DiagnosticCode.CONNECTION_FAILED) {
val fields = Json.parseToJsonElement(it.toString(Charsets.UTF_8)) as? JsonObject
(fields?.get("status") as? JsonPrimitive)?.let { status -> status.isString && status.content == "ok" } == true
} != null
}
@@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -18,6 +21,8 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
@@ -63,7 +68,7 @@ class ScannerActivity : ComponentActivity()
{
MaterialTheme()
{
Surface()
Surface(Modifier.windowInsetsPadding(WindowInsets.safeDrawing))
{
var server by remember { mutableStateOf(preferences.getString("server", null)) }
val currentServer = server
@@ -73,10 +78,10 @@ class ScannerActivity : ComponentActivity()
}
else
{
Column()
key(currentServer)
{
TextButton(onClick = { preferences.edit().remove("server").apply(); server = null }) { Text("Change server") }
Box(Modifier.weight(1f)) { ScannerHost(currentServer) }
HostChrome(currentServer, onChangeServer = { preferences.edit().remove("server").apply(); server = null })
{ brand, diagnostics -> ScannerHost(currentServer, brand, diagnostics) }
}
}
}
@@ -86,7 +91,7 @@ class ScannerActivity : ComponentActivity()
/** Server-scoped stores ensure switching installations cannot reuse a credential accidentally. */
@Composable
private fun ScannerHost(server: String)
private fun ScannerHost(server: String, brand: HostBrand, diagnostics: DiagnosticLog)
{
val credentialsResult = remember(server) { runCatching { credentialsByServer.getOrPut(server) { DeviceCredentials(applicationContext, server) } } }
val credentials = credentialsResult.getOrNull()
@@ -106,7 +111,7 @@ class ScannerActivity : ComponentActivity()
{
val hash = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) }
val dataStore = storesByServer.getOrPut(server) { PreferenceDataStoreFactory.create { File(noBackupFilesDir, "session-$hash.preferences_pb") } }
val repository = NetworkQQQRepository(server) { requestedServer ->
val repository = NetworkQQQRepository(server, requestObserver = diagnostics::nativeRequest) { requestedServer ->
if(requestedServer == server) credentials.headers() else emptyMap()
}
@Suppress("UNCHECKED_CAST")
@@ -115,6 +120,8 @@ class ScannerActivity : ComponentActivity()
}
}
val qViewModel: QViewModel = viewModel(key = server, factory = factory)
// A retained per-server ViewModel must write into this visit's fresh diagnostics log.
SideEffect { (qViewModel.qqqRepository as NetworkQQQRepository).requestObserver = diagnostics::nativeRequest }
qViewModel.setNavController(rememberNavController())
val authentication = qViewModel.authenticationMetaDataLoadState
if(authentication is LoadState.Success && authentication.value !is TableBasedAuthenticationMetaData)
@@ -122,7 +129,7 @@ class ScannerActivity : ComponentActivity()
Text("This build supports username/password sign-in. This server uses another sign-in provider. Configure that provider for this app or choose another server.", Modifier.padding(24.dp))
return
}
val api = remember(server) { DeviceApi(server, credentials) }
val api = remember(server, diagnostics) { DeviceApi(server, credentials, diagnostics::deviceRequest) }
CompositionLocalProvider(
LocalAuthenticatedContent provides { model, content -> DeviceGate(model, api, credentials, content) },
LocalCustomWidgetRenderer provides { parameters, modifier, disabled ->
@@ -135,7 +142,7 @@ class ScannerActivity : ComponentActivity()
}
)
{
QMobileAppMain(qViewModel, title = "QQQ Mobile", splashImage = null)
QMobileAppMain(qViewModel, title = brand.displayName, splashImage = null)
}
}
}
@@ -0,0 +1,91 @@
package io.qrun.mobile.scanner
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.*
import org.junit.Test
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
class HostRuntimeTest
{
@Test
fun requestDiagnosticsDistinguishNativeAndDeviceOutcomes()
{
val log = DiagnosticLog()
log.nativeRequest(RequestOutcome(401, 10))
log.nativeRequest(RequestOutcome(null, 20))
log.deviceRequest(RequestOutcome(403, 30))
log.deviceRequest(RequestOutcome(null, 40))
assertTrue(log.text().contains("NATIVE_RESPONSE HTTP=401 10ms"))
assertTrue(log.text().contains("NATIVE_TRANSPORT_FAILED HTTP=none 20ms"))
assertTrue(log.text().contains("DEVICE_RESPONSE HTTP=403 30ms"))
assertTrue(log.text().contains("DEVICE_TRANSPORT_FAILED HTTP=none 40ms"))
}
@Test
fun malformedBrandFallsBackAndUnsafeLogoIsDiscarded()
{
for(payload in listOf("not json", "[]", "null", "{\"displayName\":12,\"primaryColor\":\"red\"}"))
assertEquals(HostBrand(), HostBrand.parse(payload))
assertEquals(HostBrand("Acme", "#AB12FF"), HostBrand.parse("{\"displayName\":\" Acme \",\"primaryColor\":\"#ab12ff\",\"logoUrl\":\"http://example.com/logo.png\"}"))
assertEquals(HostBrand().displayName, HostBrand.parse("{\"displayName\":\"<script>\"}").displayName)
listOf("file:///secret", "content://secret", "https://user:pass@example.com/logo", "https://example.com/logo#secret").forEach { assertFalse(isSafeLogoUrl(it)) }
assertTrue(isSafeLogoUrl("https://example.com/logo.png"))
assertTrue(useBlackOnPrimary("#FFFFFF"))
assertTrue(useBlackOnPrimary("#FFFF00"))
assertFalse(useBlackOnPrimary("#000000"))
assertFalse(useBlackOnPrimary("#1565C0"))
}
@Test
fun serverSwitchDoesNotReuseBrandAndPublicRequestsContainNoCredentials() = runBlocking()
{
MockWebServer().use { first -> MockWebServer().use { second ->
first.enqueue(MockResponse().setBody("{\"displayName\":\"Customer A\",\"primaryColor\":\"#123456\"}"))
second.enqueue(MockResponse().setResponseCode(404))
val firstRuntime = HostRuntime(first.url("/").toString())
val secondRuntime = HostRuntime(second.url("/").toString())
assertEquals("Customer A", firstRuntime.branding().displayName)
assertEquals(HostBrand(), secondRuntime.branding())
for(request in listOf(first.takeRequest(), second.takeRequest()))
{
assertEquals("/mobile/v1/branding", request.path)
assertNull(request.getHeader("Authorization"))
assertNull(request.getHeader("Cookie"))
assertNull(request.getHeader("X-QQQ-Device-Token"))
assertEquals("no-store", request.getHeader("Cache-Control"))
}
assertFalse(secondRuntime.diagnostics.text().contains("BRAND_LOADED"))
} }
}
@Test
fun reachabilityRequiresExpectedStatusAndNeverLogsResponseData() = runBlocking()
{
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody("{\"status\":\"ok\"}"))
server.enqueue(MockResponse().setBody("<html>private-barcode-and-token</html>"))
server.enqueue(MockResponse().setResponseCode(503).setBody("private-password"))
val runtime = HostRuntime(server.url("/").toString())
assertTrue(runtime.connected())
assertFalse(runtime.connected())
assertFalse(runtime.connected())
assertFalse(runtime.diagnostics.text().contains("private"))
assertTrue(runtime.diagnostics.text().contains("CONNECTION_FAILED HTTP=503"))
}
}
@Test
fun diagnosticsAreBoundedAndClearable()
{
val log = DiagnosticLog(2)
log.record(DiagnosticCode.CONNECTION_OK, 200, 1)
log.record(DiagnosticCode.CONNECTION_FAILED, 500, 2)
log.record(DiagnosticCode.CONNECTION_FAILED, null, 3)
assertEquals(2, log.text().lines().size)
assertFalse(log.text().contains("CONNECTION_OK"))
log.clear()
assertEquals("", log.text())
}
}