feat: add customer branding and live connection diagnostics
This commit is contained in:
16 files changed
+455
-20
No files matched your search
@@ -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
@@ -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
@@ -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.
|
||||
+2
-1
@@ -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") }
|
||||
|
||||
+31
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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>
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+7
-2
@@ -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 =
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user