feat: improve diagnostics and default foreground location on
Replace the raw log dialog with readable full-screen status/activity, filtering and copy/share controls. Persist explicit location choices and request Android permission once for default-on foreground reporting. Verified 159 JVM tests, zero-warning lint, APK builds and the focused TC22 diagnostics interaction test; actual GPS fix and real-provider acceptance remain pending. Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
This commit is contained in:
14 files changed
+495
-53
No files matched your search
@@ -42,7 +42,7 @@ The persistent footer shows **Checking**, **Connected**, or **Disconnected** wit
|
||||
|
||||
## Online-only behavior
|
||||
|
||||
QQQ administrators can inspect session activity, end sessions, and request live viewing of the QQQ app from a session record. Viewing displays an indicator with **Stop viewing** on the handheld. Password/login screens and background apps cannot be mirrored. Optional foreground location requires the operator's Android permission; scanning works without it. See [session behavior and limits](docs/UI-API.md#session-activity-and-screen-viewing).
|
||||
QQQ administrators can inspect session activity, end sessions, and request live viewing of the QQQ app from a session record. Viewing displays an indicator with **Stop viewing** on the handheld. Password/login screens and background apps cannot be mirrored. Foreground location reporting defaults on and can be disabled in Settings. It requires Android permission and enabled device location providers; scanning works without it. See [session behavior and limits](docs/UI-API.md#session-activity-and-screen-viewing).
|
||||
|
||||
There is no offline queue. One submitted scan is retained in memory until a complete receipt returns the matching `acknowledgedEventId`, `scanSaved: true`, and a nonblank `scanId`. If a response is uncertain, **Retry same request** preserves its event ID and original timestamp; the server deduplicates it. Further scans wait for confirmation or explicit abandonment. Leaving after an uncertain response warns that the scan may already be saved and attempts server cancellation; if cancellation fails, **Leave anyway** lets the operator exit explicitly. Active requests and pending photos remain protected. Sign-out/reset clears the pending request; app termination loses in-memory state. Repeated physical scans deliberately receive distinct event IDs.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Every screen provides one **hamburger navigation drawer**, a clear current app/p
|
||||
| Diagnostics | Server reachability details, recent safe logs, retry/check connection and app/version information. Reachable from the connection footer and secondary menu. |
|
||||
| Administration | Approval and fleet management remain in QQQ administration. A handheld pending approval shows its short name and administrator instructions. |
|
||||
|
||||
Optional location permission belongs under device settings, not in the primary business navigation. Camera permission is requested when the operator chooses Take photo. A live viewing indicator and Stop viewing control remain visible while an administrator is viewing the app; they are not hidden in settings.
|
||||
The default-on foreground location reporting toggle, permission recovery and actual provider/fix status belong under device settings. Android location permission is requested once when an approved signed-in session first becomes foreground with reporting enabled; denial leaves scanning usable. Camera permission is requested when the operator chooses Take photo. A live viewing indicator and Stop viewing control remain visible while an administrator is viewing the app; they are not hidden in settings.
|
||||
|
||||
The owner also requires a device-local Orientation preference in Settings: System default, Portrait, Landscape, Reverse portrait and Reverse landscape. Persist the selection across app restarts and apply it across login, menus and processes. Use Android's activity orientation policy without changing system-wide rotation settings. Rotation or a policy change must preserve drafts and pending upload identity. Licenses belong in the same secondary Settings layer and must remain readable offline.
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Session state
|
||||
|
||||
## November 1 diagnostics and location update
|
||||
|
||||
Diagnostics now uses a full-screen, scrollable native view with a connection summary, local-time newest-first activity, All/Problems filters, pause/resume, copy/share/clear and Back. Existing footer and menu entry points still block process input while diagnostics is open. Location reporting defaults on for approved signed-in foreground sessions; Android grants remain required, explicit opt-out persists and automatic permission requests are not repeated after denial. Controls remain under Settings → Device permissions.
|
||||
|
||||
The combined client passes 159 JVM tests, zero-error/zero-warning debug lint, normal/E2E APK builds and the focused diagnostics interaction test on TC22 using the separate E2E package. The new UI/default policy still needs normal-package field observation. This does not establish a physical GPS fix, background tracking, real provider sign-out or production release readiness.
|
||||
|
||||
The preceding signed checkpoint1c766bf was installed in place as APK E34AC3C6…C1874EB with private backup, matching signer and no data clear. The operator and backend confirm one Code128 and one QR event on these bytes, including exact leading-zero value001234567890 from EMDK/QRCODE. Apps → menu → Scan Capture round trip was also confirmed. The normal server remains API1.9/mobile1.1; API1.10 core159 tests, current contract3 tests and coherent scanner/demo/files/browser78 cases pass separately while real Authentik acceptance continues.
|
||||
|
||||
## November 1 native authentication checkpoint
|
||||
|
||||
The optional company sign-out and original-nonce client changes pass 151 JVM tests, zero-error/zero-warning lint, debug assembly and native-test compilation. All 28 selected OAuth2 login/logout and authentication-lifecycle native tests also pass on the TC22 in the disposable sample application, with no failures or skips. The normal scanner package/data were preserved and its Activity is foreground again. These native tests use synthetic provider adapters; they do not establish real Authentik or external-browser acceptance.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# TODO
|
||||
|
||||
- [x] Replace the raw diagnostics popup with tested full-screen status/activity, filtering, pause, copy/share/clear and Back; 159 client JVM tests, zero-warning lint/build and the focused TC22 diagnostics UI test pass.
|
||||
- [x] Default foreground location reporting on, preserving explicit opt-out and one-time Android permission behavior; settings/policy tests pass.
|
||||
- [x] Verify current trusted EMDK Code128, QR leading zeros and menu/reopen on installed checkpoint1c766bf/E34AC3C6.
|
||||
- [ ] Observe the diagnostics/location update on the normal package, grant/deny/restore location permission and verify an actual provider fix; keep physical lifecycle/outage/retry and provider acceptance gates open.
|
||||
|
||||
- [x] Verify the API1.10 original-nonce and optional company-logout client patch: 151 JVM tests, zero-warning lint/build and 28 disposable TC22 native authentication/lifecycle tests pass; independent 26-file source review completed with no findings.
|
||||
- [ ] Qualify the paired strict server policy and actual Authentik A-to-company-logout-to-B handoff, including the exported native callback and real SDK/browser logging boundary; synthetic native tests do not close this gate.
|
||||
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ The approved, authenticated host sends foreground activity through the registere
|
||||
|
||||
An administrator viewing the ordinary QQQ session record renews a short screen-viewing lease through its standard widget. The host uses PixelCopy on its own app-content rectangle only, at most once per second, bounded to 640×1280 pixels and 256 KiB JPEG. No OS screen, other application or remote control is exposed. Capture requires the resumed, focused, approved authenticated screen, completed rendering and a live lease. Password screens, loading transitions, backgrounding and logout block capture and clear the server's displayed frame. Screen/session state is checked after capture and immediately before upload. Lease timing uses server-relative duration and Android's monotonic clock, not the handheld wall clock. Frames are not saved locally.
|
||||
|
||||
The operator sees a screen-viewing indicator and can stop viewing until they allow it again. Location is optional: the operator explicitly grants Android foreground location permission. Denial does not prevent scans. No background location permission is requested. Heartbeats report availability plus coordinates, accuracy and capture time only when a foreground fix exists; QQQ shows the age of the last known observation separately.
|
||||
The operator sees a screen-viewing indicator and can stop viewing until they allow it again. Foreground location reporting defaults on when no saved choice exists; an explicit saved off choice is preserved. Settings exposes the reporting toggle, precise/approximate Android permission, provider availability and fix status. The host requests coarse and fine Android permission together once, on the first approved signed-in foreground session with reporting enabled. Denial never prevents scans or triggers repeated automatic prompts; Settings links to Android permission settings for recovery. Precise permission permits GPS updates, while approximate permission uses available network location. Device location providers must also be enabled; the reporting preference cannot turn on Android GPS. No background location permission is requested or background tracking performed. Updates stop outside approved signed-in foreground sessions or when reporting is disabled. Heartbeats report coordinates, accuracy and capture time only when a foreground fix exists; QQQ shows the age of the last known observation separately. Earlier clients stored no reporting preference, so a previously missing Android grant is not treated as an explicit reporting opt-out.
|
||||
|
||||
## Record photos
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DiagnosticScreenTest
|
||||
{
|
||||
@get:Rule val ui = createAndroidComposeRule<ScannerActivity>()
|
||||
|
||||
@Test fun filtersPausesClearsAndReturnsWithoutNetworkOrCredentials()
|
||||
{
|
||||
val log = DiagnosticLog()
|
||||
log.record(DiagnosticCode.CONNECTION_OK, 200, 15)
|
||||
log.record(DiagnosticCode.DEVICE_RESPONSE, 403, 25)
|
||||
var returned = false
|
||||
ui.runOnUiThread { ui.activity.setContent { MaterialTheme { DiagnosticScreen(log) { returned = true } } } }
|
||||
ui.onNodeWithText("Reachable").assertIsDisplayed()
|
||||
ui.onNodeWithText("Problems").performScrollTo().performClick()
|
||||
ui.onNodeWithText("Device request failed").performScrollTo().assertIsDisplayed()
|
||||
ui.onNodeWithText("Server reachable").assertDoesNotExist()
|
||||
ui.onNodeWithText("Pause updates").performScrollTo().performClick()
|
||||
log.record(DiagnosticCode.SESSION_RESPONSE, 500, 50)
|
||||
ui.onNodeWithText("Session request failed").assertDoesNotExist()
|
||||
ui.onNodeWithText("Resume live updates").performScrollTo().performClick()
|
||||
ui.waitForIdle()
|
||||
ui.onNodeWithText("Session request failed").performScrollTo().assertIsDisplayed()
|
||||
ui.onNodeWithText("Clear").performClick()
|
||||
ui.onNodeWithText("No activity recorded yet. New events appear here while this screen is live.").performScrollTo().assertIsDisplayed()
|
||||
assertTrue(log.snapshot().isEmpty())
|
||||
ui.onNodeWithText("Back").performClick()
|
||||
ui.runOnIdle { assertTrue(returned) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/** Presentation accepts only the fixed, credential-free event model, never raw HTTP text. */
|
||||
internal fun DiagnosticEvent.isProblem(): Boolean = httpStatus?.let { it !in 200..299 } == true || code in setOf(
|
||||
DiagnosticCode.COMPATIBILITY_FAILED, DiagnosticCode.BRAND_DEFAULT, DiagnosticCode.LOGO_UNAVAILABLE,
|
||||
DiagnosticCode.CONNECTION_FAILED, DiagnosticCode.NATIVE_TRANSPORT_FAILED,
|
||||
DiagnosticCode.DEVICE_TRANSPORT_FAILED, DiagnosticCode.SESSION_TRANSPORT_FAILED)
|
||||
|
||||
internal fun DiagnosticEvent.title(): String = when(code)
|
||||
{
|
||||
DiagnosticCode.COMPATIBILITY_RESPONSE -> "Server compatibility checked"
|
||||
DiagnosticCode.COMPATIBILITY_FAILED -> "Server compatibility check failed"
|
||||
DiagnosticCode.BRAND_LOADED -> "Server appearance loaded"
|
||||
DiagnosticCode.BRAND_DEFAULT -> "Default appearance used"
|
||||
DiagnosticCode.LOGO_LOADED -> "Server logo loaded"
|
||||
DiagnosticCode.LOGO_UNAVAILABLE -> "Server logo unavailable"
|
||||
DiagnosticCode.CONNECTION_OK -> "Server reachable"
|
||||
DiagnosticCode.CONNECTION_FAILED -> "Connection check failed"
|
||||
DiagnosticCode.NATIVE_RESPONSE -> if(isProblem()) "App request failed" else "App request completed"
|
||||
DiagnosticCode.NATIVE_TRANSPORT_FAILED -> "App request could not connect"
|
||||
DiagnosticCode.DEVICE_RESPONSE -> if(isProblem()) "Device request failed" else "Device request completed"
|
||||
DiagnosticCode.DEVICE_TRANSPORT_FAILED -> "Device request could not connect"
|
||||
DiagnosticCode.SESSION_RESPONSE -> if(isProblem()) "Session request failed" else "Session request completed"
|
||||
DiagnosticCode.SESSION_TRANSPORT_FAILED -> "Session request could not connect"
|
||||
}
|
||||
|
||||
internal fun DiagnosticEvent.detail(): String
|
||||
{
|
||||
val result = when(httpStatus)
|
||||
{
|
||||
null -> "No HTTP response"
|
||||
401 -> "HTTP 401 · Sign-in required or expired"
|
||||
403 -> "HTTP 403 · Request not permitted"
|
||||
in 500..599 -> "HTTP $httpStatus · Server error"
|
||||
else -> "HTTP $httpStatus"
|
||||
}
|
||||
val validation = if(code == DiagnosticCode.CONNECTION_FAILED && httpStatus in 200..299) " · Unexpected status response" else ""
|
||||
return "$result$validation · ${elapsedMillis} ms"
|
||||
}
|
||||
|
||||
internal fun DiagnosticEvent.localTime(zone: ZoneId = ZoneId.systemDefault()): String =
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withZone(zone).format(time)
|
||||
|
||||
internal fun diagnosticRows(events: List<DiagnosticEvent>, problemsOnly: Boolean): List<DiagnosticEvent> =
|
||||
events.asReversed().filter { !problemsOnly || it.isProblem() }
|
||||
|
||||
internal fun latestConnectionCheck(events: List<DiagnosticEvent>): DiagnosticEvent? = events.lastOrNull {
|
||||
it.code == DiagnosticCode.CONNECTION_OK || it.code == DiagnosticCode.CONNECTION_FAILED
|
||||
}
|
||||
|
||||
internal fun diagnosticReport(events: List<DiagnosticEvent>, problemsOnly: Boolean, zone: ZoneId = ZoneId.systemDefault()): String
|
||||
{
|
||||
val rows = diagnosticRows(events, problemsOnly)
|
||||
val date = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(zone)
|
||||
return buildString {
|
||||
appendLine("QQQ Mobile connection diagnostics")
|
||||
appendLine("Connection checks measure server reachability only; sign-in and device approval are separate.")
|
||||
appendLine(if(problemsOnly) "Problems · newest first" else "All events · newest first")
|
||||
if(rows.isEmpty()) append("No events.")
|
||||
rows.forEach { appendLine("${date.format(it.time)} · ${it.title()} · ${it.detail()}") }
|
||||
}.trimEnd()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** A dedicated window keeps process input blocked while diagnostics are being inspected. */
|
||||
@Composable
|
||||
internal fun DiagnosticScreen(log: DiagnosticLog, onBack: () -> Unit)
|
||||
{
|
||||
var events by remember(log) { mutableStateOf(log.snapshot()) }
|
||||
var paused by remember(log) { mutableStateOf(false) }
|
||||
var problemsOnly by remember(log) { mutableStateOf(false) }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(log, paused)
|
||||
{
|
||||
while(!paused)
|
||||
{
|
||||
events = log.snapshot()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
val rows = diagnosticRows(events, problemsOnly)
|
||||
Dialog(onDismissRequest = onBack, properties = DialogProperties(usePlatformDefaultWidth = false))
|
||||
{
|
||||
Surface(Modifier.fillMaxSize())
|
||||
{
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding())
|
||||
{
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp))
|
||||
{
|
||||
TextButton(onClick = onBack) { Text("Back") }
|
||||
Text("Diagnostics", Modifier.padding(12.dp).weight(1f).semantics { heading() }, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
HorizontalDivider()
|
||||
LazyColumn(Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
item()
|
||||
{
|
||||
ConnectionSummary(events, paused)
|
||||
}
|
||||
item()
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||
{
|
||||
Text("Recent activity", style = MaterialTheme.typography.titleMedium, modifier = Modifier.semantics { heading() })
|
||||
Text("${rows.size} shown · Latest 100 events · Newest first", style = MaterialTheme.typography.bodySmall)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
FilterChip(selected = !problemsOnly, onClick = { problemsOnly = false }, label = { Text("All") })
|
||||
FilterChip(selected = problemsOnly, onClick = { problemsOnly = true }, label = { Text("Problems") })
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
if(!paused) events = log.snapshot()
|
||||
paused = !paused
|
||||
}) { Text(if(paused) "Resume live updates" else "Pause updates") }
|
||||
}
|
||||
}
|
||||
if(rows.isEmpty()) item()
|
||||
{
|
||||
Text(if(problemsOnly && events.isNotEmpty()) "No problems in the recorded activity." else "No activity recorded yet. New events appear here while this screen is live.",
|
||||
style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(vertical = 24.dp))
|
||||
}
|
||||
items(rows) { event -> DiagnosticEventRow(event) }
|
||||
item()
|
||||
{
|
||||
Text("Safe to share: no credentials, scan values, server addresses or response bodies are recorded.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), horizontalArrangement = Arrangement.SpaceEvenly)
|
||||
{
|
||||
TextButton(modifier = Modifier.weight(1f), enabled = rows.isNotEmpty(), onClick = {
|
||||
clipboard.setText(AnnotatedString(diagnosticReport(events, problemsOnly)))
|
||||
scope.launch { snackbar.showSnackbar("Diagnostics copied") }
|
||||
}) { Text("Copy") }
|
||||
TextButton(modifier = Modifier.weight(1f), enabled = rows.isNotEmpty(), onClick = {
|
||||
val intent = Intent(Intent.ACTION_SEND).setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, diagnosticReport(events, problemsOnly))
|
||||
try { context.startActivity(Intent.createChooser(intent, "Share diagnostics")) }
|
||||
catch(exception: ActivityNotFoundException) { scope.launch { snackbar.showSnackbar("No app is available to share diagnostics") } }
|
||||
}) { Text("Share") }
|
||||
TextButton(modifier = Modifier.weight(1f), enabled = events.isNotEmpty(), onClick = {
|
||||
log.clear()
|
||||
events = emptyList()
|
||||
scope.launch { snackbar.showSnackbar("Activity cleared") }
|
||||
}) { Text("Clear") }
|
||||
}
|
||||
SnackbarHost(snackbar)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionSummary(events: List<DiagnosticEvent>, paused: Boolean)
|
||||
{
|
||||
val check = latestConnectionCheck(events)
|
||||
val reachable = check?.code == DiagnosticCode.CONNECTION_OK
|
||||
Card(colors = CardDefaults.cardColors(containerColor = if(check != null && !reachable) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant))
|
||||
{
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Text("Server connection", style = MaterialTheme.typography.labelLarge)
|
||||
Text(when { check == null -> "No check recorded"; reachable -> "Reachable"; else -> "Check failed" },
|
||||
style = MaterialTheme.typography.headlineSmall)
|
||||
if(check != null)
|
||||
{
|
||||
Text("Last check ${check.localTime()} · ${check.elapsedMillis} ms", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text(if(paused) "Updates paused · Showing a saved snapshot" else "Live · Activity updates automatically", style = MaterialTheme.typography.bodySmall)
|
||||
Text("Reachability only. Sign-in and device approval are checked separately.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiagnosticEventRow(event: DiagnosticEvent)
|
||||
{
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||
{
|
||||
Text(event.localTime(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(event.title(), style = MaterialTheme.typography.titleSmall,
|
||||
color = if(event.isProblem()) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
|
||||
Text(event.detail(), style = MaterialTheme.typography.bodyMedium)
|
||||
HorizontalDivider(Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
@@ -2,72 +2,132 @@
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Bundle
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import java.time.Instant
|
||||
|
||||
internal data class LocationReport(val status: String = "UNAVAILABLE", val fix: LocationFix? = null)
|
||||
internal data class LocationFix(val latitude: Double, val longitude: Double, val accuracyMeters: Double, val capturedAt: String)
|
||||
|
||||
/** Optional foreground-only telemetry. Denying permission never prevents scanning. */
|
||||
/** Default-on foreground-only telemetry. Denying permission never prevents scanning. */
|
||||
internal class ForegroundLocationState
|
||||
{
|
||||
var report by mutableStateOf(LocationReport())
|
||||
var permissionRevision by mutableStateOf(0)
|
||||
var enabled by mutableStateOf(true)
|
||||
var permitted by mutableStateOf(false)
|
||||
var precise by mutableStateOf(false)
|
||||
var providersEnabled by mutableStateOf(false)
|
||||
var active by mutableStateOf(false)
|
||||
var setEnabled: (Boolean) -> Unit = {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberForegroundLocation(active: Boolean): ForegroundLocationState
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val state = remember { ForegroundLocationState() }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val preferences = remember(context) { context.getSharedPreferences("host", Context.MODE_PRIVATE) }
|
||||
val state = remember {
|
||||
ForegroundLocationState().apply {
|
||||
enabled = LocationPolicy.enabled(if(preferences.contains("locationReportingEnabled")) preferences.getBoolean("locationReportingEnabled", true) else null)
|
||||
setEnabled = { value ->
|
||||
preferences.edit().putBoolean("locationReportingEnabled", value).apply()
|
||||
enabled = value
|
||||
if(!value) report = LocationReport("DISABLED")
|
||||
}
|
||||
}
|
||||
}
|
||||
val manager = remember { context.getSystemService(LocationManager::class.java) }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions())
|
||||
{ state.permissionRevision++ }
|
||||
DisposableEffect(lifecycleOwner, context)
|
||||
{
|
||||
val observer = LifecycleEventObserver { _, event -> if(event == Lifecycle.Event.ON_RESUME) state.permissionRevision++ }
|
||||
val receiver = object : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context?, intent: Intent?) { state.permissionRevision++ }
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
ContextCompat.registerReceiver(context, receiver, IntentFilter().apply {
|
||||
addAction(LocationManager.MODE_CHANGED_ACTION)
|
||||
addAction(LocationManager.PROVIDERS_CHANGED_ACTION)
|
||||
}, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer); context.unregisterReceiver(receiver) }
|
||||
}
|
||||
val permissionRevision = state.permissionRevision
|
||||
val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
DisposableEffect(active, permitted, permissionRevision)
|
||||
LaunchedEffect(active, state.enabled, permitted, permissionRevision)
|
||||
{
|
||||
if(LocationPolicy.shouldRequestPermission(active, state.enabled, permitted, preferences.getBoolean("locationPermissionRequested", false)))
|
||||
{
|
||||
// Record before launching: recreation, resume and denial must not prompt again.
|
||||
preferences.edit().putBoolean("locationPermissionRequested", true).apply()
|
||||
launcher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
|
||||
}
|
||||
}
|
||||
DisposableEffect(active, state.enabled, permitted, permissionRevision)
|
||||
{
|
||||
state.active = active
|
||||
state.permitted = permitted
|
||||
state.precise = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
val providers = manager.getProviders(true).filter { it == LocationManager.GPS_PROVIDER || it == LocationManager.NETWORK_PROVIDER }
|
||||
state.providersEnabled = providers.isNotEmpty()
|
||||
var listening = true
|
||||
val listener = object : LocationListener
|
||||
{
|
||||
override fun onLocationChanged(location: Location)
|
||||
{
|
||||
if(!location.hasAccuracy()) return
|
||||
if(!listening || !active || !state.enabled || !location.hasAccuracy()) return
|
||||
state.report = LocationReport("AVAILABLE", LocationFix(location.latitude, location.longitude,
|
||||
location.accuracy.toDouble(), Instant.ofEpochMilli(location.time).toString()))
|
||||
}
|
||||
override fun onProviderEnabled(provider: String) { state.report = LocationReport() }
|
||||
override fun onProviderDisabled(provider: String)
|
||||
{
|
||||
state.report = LocationReport(if(manager.getProviders(true).any { it == "gps" || it == "network" }) "UNAVAILABLE" else "DISABLED")
|
||||
}
|
||||
override fun onProviderEnabled(provider: String) { state.permissionRevision++ }
|
||||
override fun onProviderDisabled(provider: String) { state.permissionRevision++ }
|
||||
@Deprecated("Legacy callback") override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
|
||||
}
|
||||
state.report = LocationReport(if(!permitted) "PERMISSION_DENIED" else "UNAVAILABLE")
|
||||
if(active && permitted)
|
||||
state.report = LocationReport(when {
|
||||
!state.enabled -> "DISABLED"
|
||||
!permitted -> "PERMISSION_DENIED"
|
||||
!state.providersEnabled -> "DISABLED"
|
||||
else -> "UNAVAILABLE"
|
||||
})
|
||||
if(active && state.enabled && permitted)
|
||||
{
|
||||
val providers = manager.getProviders(true).filter { it == LocationManager.GPS_PROVIDER || it == LocationManager.NETWORK_PROVIDER }
|
||||
if(providers.isEmpty()) state.report = LocationReport("DISABLED")
|
||||
for(provider in providers)
|
||||
for(provider in providers.filter { state.precise || it != LocationManager.GPS_PROVIDER })
|
||||
{
|
||||
try { manager.requestLocationUpdates(provider, 10000L, 10f, listener) }
|
||||
catch(exception: SecurityException) { state.report = LocationReport("PERMISSION_DENIED") }
|
||||
catch(exception: SecurityException) { state.report = LocationReport("PERMISSION_DENIED"); state.permitted = false }
|
||||
}
|
||||
}
|
||||
onDispose { manager.removeUpdates(listener) }
|
||||
onDispose { listening = false; manager.removeUpdates(listener); state.report = LocationReport() }
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -75,11 +135,22 @@ internal fun rememberForegroundLocation(active: Boolean): ForegroundLocationStat
|
||||
@Composable
|
||||
internal fun LocationPermissionControl(state: ForegroundLocationState)
|
||||
{
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions())
|
||||
{ state.permissionRevision++ }
|
||||
if(state.report.status == "PERMISSION_DENIED")
|
||||
val context = LocalContext.current
|
||||
Row {
|
||||
Text("Report foreground location")
|
||||
Switch(checked = state.enabled, onCheckedChange = state.setEnabled)
|
||||
}
|
||||
Text(LocationPolicy.status(state.enabled, state.permitted, state.providersEnabled, state.active, state.report.status))
|
||||
if(state.permitted) Text(if(state.precise) "Android permission: precise location" else "Android permission: approximate location")
|
||||
Text("Default on. Reporting requires Android permission and an approved signed-in foreground session. Scanning works without location.")
|
||||
if(state.enabled && !state.permitted)
|
||||
{
|
||||
TextButton(onClick = { launcher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) })
|
||||
{ Text("Enable device location (optional)") }
|
||||
TextButton(onClick = { context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))) })
|
||||
{ Text("Open location permission settings") }
|
||||
}
|
||||
else if(state.enabled && !state.providersEnabled)
|
||||
{
|
||||
TextButton(onClick = { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) })
|
||||
{ Text("Open device location settings") }
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,14 @@ import kotlinx.coroutines.CancellationException
|
||||
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
|
||||
@@ -48,7 +44,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
|
||||
var checkedAt by remember(server) { mutableStateOf("not checked") }
|
||||
var showDiagnostics by remember(server) { mutableStateOf(false) }
|
||||
val parentInputAllowed = LocalProcessInputAllowed.current
|
||||
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() }
|
||||
@@ -68,7 +63,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
|
||||
brand = runtime.branding()
|
||||
compatible = true
|
||||
logo = brand.logoUrl?.let { runtime.logo(it) }
|
||||
diagnosticText = runtime.diagnostics.text()
|
||||
}
|
||||
LaunchedEffect(runtime, lifecycle, compatible)
|
||||
{
|
||||
@@ -90,7 +84,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
|
||||
{
|
||||
connection = if(runtime.connected()) "Connected" else "Disconnected"
|
||||
checkedAt = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
|
||||
diagnosticText = runtime.diagnostics.text()
|
||||
withTimeoutOrNull(5000) { changed.receive() }
|
||||
}
|
||||
}
|
||||
@@ -115,7 +108,7 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
|
||||
Box(Modifier.weight(1f))
|
||||
{
|
||||
if(compatible) CompositionLocalProvider(
|
||||
LocalOpenDiagnostics provides { diagnosticText = runtime.diagnostics.text(); showDiagnostics = true },
|
||||
LocalOpenDiagnostics provides { showDiagnostics = true },
|
||||
LocalProcessInputAllowed provides { parentInputAllowed() && !showDiagnostics })
|
||||
{ content(brand, runtime.diagnostics, runtime.compatibility, onChangeServer) }
|
||||
else Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
@@ -125,31 +118,12 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = { diagnosticText = runtime.diagnostics.text(); showDiagnostics = true }, modifier = Modifier.fillMaxWidth())
|
||||
TextButton(onClick = { 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") }
|
||||
}
|
||||
})
|
||||
}
|
||||
if(showDiagnostics) DiagnosticScreen(runtime.diagnostics, onBack = { showDiagnostics = false })
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ internal class DiagnosticLog(private val capacity: Int = 100)
|
||||
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 snapshot(): List<DiagnosticEvent> = events.toList()
|
||||
@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)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
/** The reporting preference and Android permission are independent controls. */
|
||||
internal object LocationPolicy
|
||||
{
|
||||
fun enabled(saved: Boolean?) = saved ?: true
|
||||
|
||||
fun shouldRequestPermission(active: Boolean, enabled: Boolean, permitted: Boolean, previouslyRequested: Boolean) =
|
||||
active && enabled && !permitted && !previouslyRequested
|
||||
|
||||
fun status(enabled: Boolean, permitted: Boolean, providersEnabled: Boolean, active: Boolean, report: String): String = when
|
||||
{
|
||||
!enabled -> "Location reporting is off"
|
||||
!permitted -> "Location reporting is on; Android location permission is not granted"
|
||||
!providersEnabled -> "Location reporting is on; device location providers are off"
|
||||
!active -> "Location reporting is on; waiting for an approved signed-in foreground session"
|
||||
report == "AVAILABLE" -> "Location reporting is on; foreground location is available"
|
||||
else -> "Location reporting is on; waiting for a foreground location fix"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class DiagnosticPresentationTest
|
||||
{
|
||||
private fun event(code: DiagnosticCode, status: Int? = 200) = DiagnosticEvent(Instant.parse("2026-11-01T18:00:00Z"), code, status, 12)
|
||||
|
||||
@Test fun problemsIncludeTransportHttpAndInvalidSuccessfulResponses()
|
||||
{
|
||||
val success = event(DiagnosticCode.CONNECTION_OK)
|
||||
val forbidden = event(DiagnosticCode.DEVICE_RESPONSE, 403)
|
||||
val malformed = event(DiagnosticCode.CONNECTION_FAILED)
|
||||
val transport = event(DiagnosticCode.NATIVE_TRANSPORT_FAILED, null)
|
||||
assertEquals(listOf(transport, malformed, forbidden), diagnosticRows(listOf(success, forbidden, malformed, transport), true))
|
||||
assertEquals(listOf(transport, malformed, forbidden, success), diagnosticRows(listOf(success, forbidden, malformed, transport), false))
|
||||
assertTrue(malformed.detail().contains("Unexpected status response"))
|
||||
assertTrue(transport.detail().contains("No HTTP response"))
|
||||
}
|
||||
|
||||
@Test fun connectionSummaryNeverUsesAppDeviceOrSessionResponses()
|
||||
{
|
||||
val check = event(DiagnosticCode.CONNECTION_FAILED, 503)
|
||||
val activity = listOf(event(DiagnosticCode.DEVICE_RESPONSE), event(DiagnosticCode.SESSION_RESPONSE), event(DiagnosticCode.NATIVE_RESPONSE))
|
||||
assertNull(latestConnectionCheck(activity))
|
||||
assertEquals(check, latestConnectionCheck(listOf(check) + activity))
|
||||
assertEquals("Device request completed", activity[0].title())
|
||||
assertEquals("Session request completed", activity[1].title())
|
||||
}
|
||||
|
||||
@Test fun exportUsesFriendlyLabelsLocalDateAndFixedFieldsOnly()
|
||||
{
|
||||
val events = DiagnosticCode.values().map { event(it, 401) }
|
||||
val report = diagnosticReport(events, false, ZoneId.of("America/Chicago"))
|
||||
assertTrue(report.contains("2026-11-01 12:00:00 CST"))
|
||||
assertTrue(report.contains("Sign-in required or expired"))
|
||||
assertTrue(report.contains("reachability only"))
|
||||
DiagnosticCode.values().forEach { assertFalse(report.contains(it.name)) }
|
||||
listOf("http://", "https://", "Cookie", "Authorization", "token=", "password=", "?").forEach { assertFalse(report.contains(it)) }
|
||||
assertTrue(diagnosticReport(emptyList(), true).endsWith("No events."))
|
||||
}
|
||||
|
||||
@Test fun snapshotsAreIndependentBoundedAndClearable()
|
||||
{
|
||||
val log = DiagnosticLog(2)
|
||||
log.record(DiagnosticCode.CONNECTION_OK, 200, 1)
|
||||
val saved = log.snapshot()
|
||||
log.record(DiagnosticCode.DEVICE_RESPONSE, 403, 2)
|
||||
log.record(DiagnosticCode.SESSION_RESPONSE, 500, 3)
|
||||
assertEquals(1, saved.size)
|
||||
assertEquals(listOf(DiagnosticCode.DEVICE_RESPONSE, DiagnosticCode.SESSION_RESPONSE), log.snapshot().map { it.code })
|
||||
log.clear()
|
||||
assertTrue(log.snapshot().isEmpty())
|
||||
assertEquals(1, saved.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LocationPolicyTest
|
||||
{
|
||||
@Test fun missingPreferenceEnablesReportingAndExplicitChoicesArePreserved()
|
||||
{
|
||||
assertTrue(LocationPolicy.enabled(null))
|
||||
assertTrue(LocationPolicy.enabled(true))
|
||||
assertFalse(LocationPolicy.enabled(false))
|
||||
}
|
||||
|
||||
@Test fun initialPermissionRequestRequiresActiveSessionAndEnabledPreference()
|
||||
{
|
||||
assertTrue(LocationPolicy.shouldRequestPermission(true, true, false, false))
|
||||
assertFalse(LocationPolicy.shouldRequestPermission(false, true, false, false))
|
||||
assertFalse(LocationPolicy.shouldRequestPermission(true, false, false, false))
|
||||
assertFalse(LocationPolicy.shouldRequestPermission(true, true, true, false))
|
||||
}
|
||||
|
||||
@Test fun denialDoesNotPromptAgainOnResumeRecreationOrNextSession()
|
||||
{
|
||||
listOf(false, true, false, true).forEach { active ->
|
||||
assertFalse(LocationPolicy.shouldRequestPermission(active, true, false, true))
|
||||
}
|
||||
assertTrue(LocationPolicy.enabled(null))
|
||||
}
|
||||
|
||||
@Test fun settingsDistinguishOptOutPermissionProvidersAndAnActualFix()
|
||||
{
|
||||
assertEquals("Location reporting is off", LocationPolicy.status(false, true, true, true, "AVAILABLE"))
|
||||
assertEquals("Location reporting is on; Android location permission is not granted", LocationPolicy.status(true, false, true, true, "UNAVAILABLE"))
|
||||
assertEquals("Location reporting is on; device location providers are off", LocationPolicy.status(true, true, false, true, "AVAILABLE"))
|
||||
assertEquals("Location reporting is on; waiting for an approved signed-in foreground session", LocationPolicy.status(true, true, true, false, "AVAILABLE"))
|
||||
assertEquals("Location reporting is on; waiting for a foreground location fix", LocationPolicy.status(true, true, true, true, "UNAVAILABLE"))
|
||||
assertEquals("Location reporting is on; foreground location is available", LocationPolicy.status(true, true, true, true, "AVAILABLE"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user