Files
qqq-android/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt
T
james.maesandCodex 476210ae22
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Canceled after 4m31s
QRun-IO/ci-policy: ci / android-0 (pull_request) Canceled after 4m31s
feat: browse mobile records and attach photos through record actions
Preserve table search, filters and paging; add grouped details, related-record tabs, formatted values and parent-scoped saved photo previews. Retain upload guards across rotation and support GS1 AIM metadata.

Validate with 232 unit tests, clean lint, isolated native semantic acceptance and actual TC22 visual checks.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-05 06:23:25 -06:00

450 lines
25 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.graphics.Rect
import androidx.core.content.ContextCompat
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.rememberNavController
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
import com.kingsrook.qqq.frontend.android.core.controllers.NetworkQQQRepository
import com.kingsrook.qqq.frontend.android.core.model.Environment
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.TableBasedAuthenticationMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData
import com.kingsrook.qqq.frontend.android.mobileapp.QMobileAppMain
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalAuthenticatedContent
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalCustomWidgetRenderer
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.LocalDeviceSettings
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.LocalBusinessNavigationEnabled
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
import java.io.File
import java.net.URI
import java.security.MessageDigest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
/*******************************************************************************
** Deployable generic host: select a server, authenticate, enroll, then run its QQQ mobile apps.
*******************************************************************************/
class ScannerActivity : ComponentActivity() {
private var resumed by mutableStateOf(false)
private var focused by mutableStateOf(false)
private var captureBounds by mutableStateOf<Rect?>(null)
private val windowFocusObserver = android.view.ViewTreeObserver.OnWindowFocusChangeListener { focused = it }
private var managed by mutableStateOf(ManagedValues())
private var watchingManagedConfiguration = false
private val restrictionsReceiver = object : BroadcastReceiver() {
/*******************************************************************************
** Reread administrator restrictions when the system reports a change while this host is visible.
*******************************************************************************/
override fun onReceive(context: Context, intent: Intent) = refreshManagedConfiguration()
}
/*******************************************************************************
** Mark the host resumed so visible scanner ownership and foreground telemetry can be reevaluated.
*******************************************************************************/
override fun onResume() {
super.onResume()
resumed = true
window.decorView.post { if (resumed) focused = window.decorView.hasWindowFocus() }
startWatchingManagedConfiguration()
}
/*******************************************************************************
** Withdraw foreground eligibility before Android pauses the activity and scanner input.
*******************************************************************************/
override fun onPause() {
resumed = false; stopWatchingManagedConfiguration(); super.onPause()
}
/*******************************************************************************
** Reread managed values (also covers changes missed while paused) and apply a changed orientation default.
*******************************************************************************/
private fun refreshManagedConfiguration() {
managed = readManagedValues(this)
val preferences = getSharedPreferences("host", MODE_PRIVATE)
val target = ManagedConfiguration.orientationToApply(managed.orientation, preferences.getString("managed_orientation_applied", null)) ?: return
preferences.edit().putString("managed_orientation_applied", target.name).apply()
target.select(this)
}
/*******************************************************************************
** Observe restriction changes only while the host is resumed; the system broadcast is not exported to other apps.
*******************************************************************************/
private fun startWatchingManagedConfiguration() {
refreshManagedConfiguration()
if (watchingManagedConfiguration) return
ContextCompat.registerReceiver(this, restrictionsReceiver, IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED)
watchingManagedConfiguration = true
}
/*******************************************************************************
** Stop observing before the activity leaves the foreground.
*******************************************************************************/
private fun stopWatchingManagedConfiguration() {
if (!watchingManagedConfiguration) return
unregisterReceiver(restrictionsReceiver)
watchingManagedConfiguration = false
}
/*******************************************************************************
** Reflect focus changes so obscured windows cannot keep accepting scanner input.
*******************************************************************************/
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus); focused = hasFocus
}
/*******************************************************************************
** Detach window focus observation before Android destroys the activity; composed host disposal owns session cleanup.
*******************************************************************************/
override fun onDestroy() {
val observer = window.decorView.viewTreeObserver
if (observer.isAlive) observer.removeOnWindowFocusChangeListener(windowFocusObserver)
super.onDestroy()
}
/*******************************************************************************
** Retain server-scoped client dependencies across activity recreation without sharing credentials between servers.
*******************************************************************************/
companion object {
private val credentialsByServer = mutableMapOf<String, DeviceCredentials>()
private val storesByServer = mutableMapOf<String, DataStore<Preferences>>()
}
/*******************************************************************************
** Restore local host policy and compose the existing QQQ client around enrollment and scanner controls.
*******************************************************************************/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.decorView.viewTreeObserver.addOnWindowFocusChangeListener(windowFocusObserver)
refreshManagedConfiguration()
OrientationPolicy.read(this).apply(this)
val preferences = getSharedPreferences("host", MODE_PRIVATE)
setContent()
{
MaterialTheme()
{
Surface(Modifier.windowInsetsPadding(WindowInsets.safeDrawing).onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
captureBounds = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
})
{
var server by remember { mutableStateOf(preferences.getString("server", null)) }
val currentServer = server
if (currentServer == null) {
ServerForm(managed.serverUrl) { value -> preferences.edit().putString("server", value).apply(); server = value }
} else {
key(currentServer)
{
val scope = rememberCoroutineScope()
var beforeChange by remember { mutableStateOf<suspend () -> Unit>({}) }
var changing by remember { mutableStateOf(false) }
HostChrome(currentServer, onChangeServer = {
if (!changing) scope.launch {
changing = true
beforeChange()
preferences.edit().remove("server").apply()
server = null
}
})
{ brand, diagnostics, compatibility, changeServer -> ScannerHost(currentServer, brand, diagnostics, compatibility, changeServer) { beforeChange = it } }
}
}
}
}
}
}
/*******************************************************************************
** Server-scoped stores ensure switching installations cannot reuse a credential accidentally.
*******************************************************************************/
@Composable
private fun ScannerHost(
server: String, brand: HostBrand, diagnostics: DiagnosticLog, compatibility: UiApiCompatibility, changeServer: () -> Unit,
registerBeforeChange: (suspend () -> Unit) -> Unit
) {
val credentialsResult = remember(server) { runCatching { credentialsByServer.getOrPut(server) { DeviceCredentials(applicationContext, server) } } }
val credentials = credentialsResult.getOrNull()
if (credentials == null) {
Text("Device credentials could not be opened. Ask your administrator to reset this installation.", Modifier.padding(24.dp))
return
}
val factory = remember(server)
{
QAppContainer.availableEnvironments = listOf(Environment("QQQ server", server))
QViewModel.applicationName = "qqq-mobile-scanners"
QViewModel.applicationVersion = BuildConfig.VERSION_NAME
object : ViewModelProvider.Factory {
/*******************************************************************************
** Capture this server's repository and preference store in its ViewModel instead of reusing another host's state.
*******************************************************************************/
override fun <T : ViewModel> create(modelClass: Class<T>): T {
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, requestObserver = diagnostics::nativeRequest, compatibility = compatibility) { requestedServer ->
if (requestedServer == server) credentials.headers() else emptyMap()
}
@Suppress("UNCHECKED_CAST")
return QViewModel(repository, dataStore).apply { supportedCustomWidgetTypes = setOf("SCANNER", "RECORD_FILES_CAPTURE") } as T
}
}
}
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.changeServerAction = changeServer
}
qViewModel.setNavController(rememberNavController())
val authentication = qViewModel.authenticationMetaDataLoadState
if (authentication is LoadState.Success && authentication.value !is TableBasedAuthenticationMetaData && authentication.value !is OAuth2AuthenticationMetaData) {
Text("This app supports QQQ password sign-in and server-advertised OAuth2 browser sign-in. This server uses an unsupported provider.", Modifier.padding(24.dp))
return
}
val api = remember(server, diagnostics) { DeviceApi(server, credentials, diagnostics::deviceRequest, compatibility) }
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, credentials::headers, compatibility, diagnostics::nativeRequest) }
var approved by remember(qViewModel.sessionUUID) { mutableStateOf(false) }
val location = rememberForegroundLocation(resumed && approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested)
val currentScreen = sessionScreen(qViewModel, resumed, focused, approved, captureBounds != null)
var renderedScreen by remember(qViewModel.sessionUUID) { mutableStateOf<SessionScreen?>(null) }
LaunchedEffect(currentScreen)
{
// Metadata may change before Compose replaces the previous screen's pixels.
withFrameNanos { }
withFrameNanos { }
renderedScreen = currentScreen
}
// Display-only wallpaper status: approved name and sign-in state, never the session id. Cleared when this host leaves composition.
val signedIn = approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested
val serverHost = remember(server) { runCatching { URI(server).host }.getOrNull() }
LaunchedEffect(signedIn, resumed, qViewModel.sessionUserFullName, brand)
{
WallpaperStatus.store.setBrand(brand.displayName, brand.primaryColor)
if (signedIn) WallpaperStatus.store.signedIn(qViewModel.sessionUserFullName, serverHost, resumed)
else WallpaperStatus.store.signedOutIfSignedIn()
}
DisposableEffect(server) { onDispose { WallpaperStatus.store.clear() } }
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, credentials::headers, compatibility, diagnostics::sessionRequest) }
val deviceSharing = remember(server, qViewModel.sessionUUID) { DeviceScreenSharing(applicationContext) }
val sessions = remember(sessionApi, qViewModel.sessionUUID)
{
val monitoredSession = qViewModel.sessionUUID
MobileSessionController(
qViewModel.sessionUUID, sessionApi,
readScreen = {
val screen = sessionScreen(qViewModel, resumed, focused, approved, captureBounds != null)
if (deviceSharing.active) screen.copy(ready = true, captureMode = CaptureMode.DEVICE_SCREEN, location = location.report)
else screen.copy(ready = screen.ready && screen == renderedScreen, location = location.report)
},
capture = { lease ->
if (lease.captureMode == CaptureMode.DEVICE_SCREEN) deviceSharing.frame(lease)
else captureBounds?.let { captureAppScreen(window, it, lease) }
},
onUnauthorized = {
runOnUiThread {
if (qViewModel.sessionUUID == monitoredSession) qViewModel.requestLogout()
}
}, onDeviceViewingStopped = deviceSharing::stop, onDeviceLease = deviceSharing::boundLease
)
}
DisposableEffect(sessions)
{
qViewModel.beforeLogout = { deviceSharing.stop(); sessions.end("SIGN_OUT") }
registerBeforeChange { sessions.end("SERVER_CHANGE"); qViewModel.logOut().join() }
onDispose { deviceSharing.stop(); sessions.invalidate() }
}
SideEffect {
deviceSharing.onSystemStop = sessions::pauseViewing
deviceSharing.onStarted = sessions::allowViewing
}
LaunchedEffect(sessions, approved, qViewModel.isAuthenticated, qViewModel.isLogoutRequested)
{
if (approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested) sessions.run()
}
CompositionLocalProvider(
LocalCompanySignOutEnabled provides true,
LocalBusinessNavigationEnabled provides approved,
LocalDeviceSettings provides {
OrientationSetting(this@ScannerActivity)
WallpaperSetting(this@ScannerActivity)
ManagedServerNotice(server, managed.serverUrl) {
qViewModel.qNavigator.requestLeave("Change server and sign out?", "Change server", alwaysConfirm = true) { qViewModel.changeServerAction?.invoke() }
}
DeviceScreenSharingControl(deviceSharing, sessions) {
approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested
}
if (api.shortName.isNotBlank()) Text("Device: ${api.shortName}")
if (api.displayName.isNotBlank()) Text("Name: ${api.displayName}")
Text("Device permissions", style = MaterialTheme.typography.titleSmall)
LocationPermissionControl(location)
AboutButton()
TextButton(onClick = {
qViewModel.qNavigator.requestLeave("Change server and sign out?", "Change server", alwaysConfirm = true) { qViewModel.changeServerAction?.invoke() }
}) { Text("Change server") }
},
LocalAuthenticatedContent provides { model, content ->
DeviceGate(model, api, credentials, { approved = it }) {
Column {
ScreenViewingIndicator(sessions)
content()
}
}
},
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.LocalRecordAttachments provides { model, tableName, recordId, recordUi ->
RecordPhotoCapture(model, RecordPhotoTarget(tableName, recordId, "Record photos", null), recordFiles, recordUi = recordUi,
disabled = !com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed.current())
},
LocalCustomWidgetRenderer provides { parameters, modifier, disabled ->
if (parameters.widgetBlock.blockType == "SCANNER") {
ScannerWidget(
parameters, modifier, disabled,
fidelityAvailable = { ScanFidelityCapability.available(server, compatibility.server, compatibility.verifiedVersion()) }
)
true
} else if (parameters.widgetBlock.blockType == "RECORD_FILES_CAPTURE") {
RecordPhotoWidget(parameters, recordFiles, modifier, disabled)
true
} else false
}
)
{
// HostChrome checks the actual server; an INTERNET network is not required for local USB forwarding.
QMobileAppMain(qViewModel, title = brand.displayName, splashImage = null, showNetworkDialog = false)
}
}
}
/*******************************************************************************
** URLs are origins only; release builds require HTTPS.
*******************************************************************************/
internal fun normalizeServerUrl(input: String, allowHttp: Boolean): String {
val uri = URI(input.trim())
require(uri.scheme == "https" || (allowHttp && uri.scheme == "http")) { "Use an HTTPS server URL." }
require(uri.host != null && uri.userInfo == null && uri.query == null && uri.fragment == null) { "Use a server URL without credentials, query, or fragment." }
require(uri.path.isNullOrEmpty() || uri.path == "/") { "Use the server origin without a path." }
return URI(uri.scheme.lowercase(), null, uri.host.lowercase(), uri.port, "/", null, null).toASCIIString()
}
/*******************************************************************************
** Initial server configuration is editable until the host connects.
*******************************************************************************/
@Composable
private fun ServerForm(managedUrl: String?, onConnect: (String) -> Unit) {
var url by remember(managedUrl) { mutableStateOf(managedUrl ?: if (BuildConfig.DEBUG) "http://127.0.0.1:8080/" else "https://") }
var error by remember { mutableStateOf("") }
Column(Modifier.imePadding().verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp))
{
Text("Connect QQQ Mobile", style = MaterialTheme.typography.headlineMedium)
if (managedUrl != null) Text("Your administrator configured this server. Review it, then connect.")
OutlinedTextField(url, { url = it }, label = { Text("QQQ server URL") }, singleLine = true)
Button(onClick = {
try {
onConnect(normalizeServerUrl(url, BuildConfig.DEBUG))
} catch (exception: Exception) {
error = exception.message ?: "Invalid server URL"
}
}) { Text("Connect") }
if (error.isNotBlank()) Text(error)
if (BuildConfig.DEBUG) Text("Development build: HTTP is allowed for the local demo. The default URL uses USB port forwarding; see the TC22 setup guide.")
}
}
/*******************************************************************************
** Pending, revoked, or unavailable devices cannot expose application screens.
*******************************************************************************/
@Composable
private fun DeviceGate(
viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, onApproval: (Boolean) -> Unit,
content: @Composable () -> Unit
) {
var attempt by remember { mutableIntStateOf(0) }
var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") }
var error by remember { mutableStateOf("") }
var enrollmentConflict by remember { mutableStateOf(false) }
LaunchedEffect(viewModel.sessionUUID, attempt)
{
status = "CHECKING"
onApproval(false)
error = ""
enrollmentConflict = false
try {
status = api.status(viewModel.sessionUUID, "${Build.MANUFACTURER} ${Build.MODEL}")
onApproval(status == "APPROVED")
if (status == "APPROVED") viewModel.loadQInstance()
} catch (exception: CancellationException) {
throw exception
} catch (exception: EnrollmentConflict) {
status = "ERROR"; error = exception.message ?: "Registration conflict"; enrollmentConflict = true
} catch (exception: Exception) {
status = "ERROR"; error = exception.message ?: "Device check failed."
}
}
if (status == "APPROVED") content()
else Column(Modifier.verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp))
{
Text("Device approval", style = MaterialTheme.typography.headlineMedium)
Text(
when (status) {
"CHECKING" -> "Checking device registration…"
"PENDING" -> "An administrator must approve this device before you can run mobile apps."
"REVOKED" -> "This device has been revoked. Contact your administrator."
else -> error.ifBlank { "Device status: $status" }
})
if (credentials.deviceId.isNotBlank()) Text("Device ID: ${credentials.deviceId}")
if (api.shortName.isNotBlank()) Text("Device: ${api.shortName}")
if (api.displayName.isNotBlank()) Text("Name: ${api.displayName}")
Text("Your administrator can edit this device’s name, short name and description in QQQ before approval.")
Button(enabled = status != "CHECKING", onClick = { attempt++ }) { Text("Check approval") }
if (enrollmentConflict) Button(onClick = { credentials.restartPendingEnrollment(); attempt++ }) { Text("Start new registration") }
Button(onClick = { viewModel.qNavigator.requestSignOut() }) { Text("Sign out") }
}
}