Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.2 0419cdea91 test(emu): emulator-only build type with the scan fixture under the normal application ID
android / android (push) Failing after 11m43s
build / android (push) Failing after 11m43s
A server that registers only the normal scanner callback offers the E2E
package no sign-in, so the fixture could not be used against it. The new
`emu` build type (initWith debug, no applicationIdSuffix, versionNameSuffix
-emu, label "QQQ Mobile EMULATOR TEST") reuses the E2E fixture driver source
under io.qrun.mobile.scanner. It does not take the E2E manifest or network
security config: cleartext stays off and only system trust is used.

Because it carries the normal application ID, the fixture driver is now
created only for the disposable .e2e package (unchanged behaviour) or when
android.os.Build describes an SDK emulator image: HARDWARE goldfish or
ranchu, PRODUCT sdk_*, MODEL sdk_* or "Android SDK built for *", and a
FINGERPRINT naming that same product. Otherwise there is no driver.

Debug, qa and release source sets are unchanged and contain no ingress.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:48:01 -06:00
james.maesandClaude Fable 5.2 d5c3ac2d6a test(e2e): accept the scan fixture's raw bytes as Base64 text
android / android (push) Failing after 4m0s
build / android (push) Failing after 4m0s
adb shell am broadcast cannot send a byte-array extra, so a manual emulator
scan could not carry the raw bytes a GS1-128 duplication needs. The E2E
driver now also reads io.qrun.mobile.scanner.e2e.raw_data_base64 (canonical
padded Base64). The byte-array extra still wins when both are present.
Malformed Base64, and an invalid AIM identifier (which used to throw inside
the receiver), now leave the read pending so the broadcast is ignored.

E2E source set only: no change to main, debug, qa, production or release.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:36:00 -06:00
james.maes 9bae976138 Merge pull request 'fix(auth): end the identity-provider session on sign-out and force re-authentication on sign-in' (#27) from fix/signout-idp-session into main
android / android (push) Failing after 3m53s
build / android (push) Failing after 3m53s
2026-11-09 20:10:27 +00:00
james.maesandClaude Sonnet 5.5 b87171cafc fix(auth): end the identity-provider session on sign-out and force re-authentication on sign-in
android / android (push) Failing after 3m53s
build / android (push) Failing after 3m53s
Ordinary Sign out cleared only the QQQ session; the provider session left in the
browser silently signed the same person back in (prompt=login alone was already
observed reusing retained SSO on real Authentik). Sign out now runs the provider
end-session flow when the server supports it, and every sign-in also sends
max_age=0. Stored-session restore after sign-out was checked: not a cause.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:10:00 -06:00
james.maes 3b15183f52 Merge pull request 'fix(core): allow 60 s for the metadata load and name the URL on timeout' (#26) from fix/metadata-load-timeout into main
android / android (push) Failing after 3m47s
build / android (push) Failing after 3m47s
2026-11-09 20:01:50 +00:00
james.maes b165dd34a8 Merge pull request 'fix(android): accept string widget icons and summary-only help content' (#24) from fix/widget-icon-string-main into main
android / android (push) Failing after 3m28s
build / android (push) Failing after 3m28s
2026-11-09 20:01:24 +00:00
13 changed files with 289 additions and 25 deletions

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
## 1.0.4 (versionCode 6)
- Loading the server's metadata no longer fails with "Error loading QInstance: timeout" on a handheld. The metadata response is about 1.7 MB and takes the server 4 to 8 seconds to build, and the default 10 second read timeout expired. That one call now allows 60 seconds; every other call keeps its short timeout. A timeout now reads "Timed out after 60.0s loading the server's metadata from <server>/qqq/v1/metaData".
- Sign-out now also ends the identity-provider session in the browser when the server supports it (the same flow as "Sign out of company", now behind the single **Sign out** action), and every browser sign-in sends `max_age=0` next to `prompt=login`. Before, ordinary Sign out cleared only the QQQ session, so the provider session left in the browser silently signed the same person straight back in on the next sign-in. Servers without provider logout still get local plus QQQ sign-out. Stored-session restore after sign-out was checked and is not a cause (test added).
- Includes 1.0.3 (versionCode 5): string widget icons and summary-only help content from current QQQ core are accepted. (PR 24)
## 1.0.2 (versionCode 4), candidate
+1 -1
View File
@@ -63,7 +63,7 @@ The fresh-login prompt is a request to the provider, not cryptographic proof of
## Optional company sign-out (implementation awaiting qualification)
Ordinary **Sign out** continues to end only the QQQ session. The scanner also exposes a separate **Sign out of company** action in Account when the server advertises API1.10 or later within major1, feature `nativeSessionLogout`, and OAuth2 metadata `nativeSessionLogout` with flow `OIDC_RP_INITIATED_LOGOUT` and `postLogoutRedirectUris`. Other library hosts opt into that separate action with `LocalCompanySignOutEnabled`; it is disabled by default. No automatic provider-logout policy is selected. The confirmation explains that company sign-out may affect other applications in the browser.
Since 1.0.4, **Sign out** in Account ends the QQQ session and, when the server advertises API1.10 or later within major1, feature `nativeSessionLogout`, and OAuth2 metadata `nativeSessionLogout` with flow `OIDC_RP_INITIATED_LOGOUT` and `postLogoutRedirectUris`, also ends the provider session in the browser (formerly the separate **Sign out of company** action; without that support Sign out stays QQQ-only). Other library hosts opt into provider sign-out with `LocalCompanySignOutEnabled`; it is disabled by default. No automatic provider-logout policy is selected. The confirmation explains that company sign-out may affect other applications in the browser.
Register the installed package's exact callback separately as a provider **post-logout** redirect, even when it matches the existing login callback. The client sends `{"nativeLogout":{"postLogoutRedirectUri":"<registered callback>"}}` to the existing `POST /qqq/v1/logout`. Its optional `nativeLogoutData` contains `issuer`, `endSessionEndpoint`, `postLogoutRedirectUri` and sensitive `idTokenHint`. Old sessions or unsupported servers may legitimately omit this data. The ordinary logout request remains unchanged.
+18 -1
View File
@@ -41,7 +41,24 @@ Only the disposable E2E APK accepts synthetic broadcasts while its scanner scree
adb shell am broadcast -a io.qrun.mobile.scanner.SCAN -c android.intent.category.DEFAULT -p io.qrun.mobile.scanner.e2e --es com.symbol.datawedge.data_string DEMO-001 --es com.symbol.datawedge.label_type LABEL-TYPE-CODE128
```
This validates intent parsing only; the server treats source/symbology as client-reported metadata.
`am broadcast` cannot send a byte-array extra. A scan that needs fidelity metadata (for example GS1-128) adds the AIM identifier and the raw bytes as canonical padded Base64 text, both quoted for the device shell:
```text
--es io.qrun.mobile.scanner.e2e.aim_identifier ']C1' --es io.qrun.mobile.scanner.e2e.raw_data_base64 'XUMxMDEwOTUwMTEwMTUzMDAwMzExMjAxMDE0MjFURVNUMDAwMTIz'
```
The byte-array extra `io.qrun.mobile.scanner.e2e.raw_data` wins when both are present. A broadcast with malformed Base64 or an invalid AIM identifier is ignored and the read stays pending. `am to-intent-uri` with the same arguments prints how the device parsed them.
This validates intent parsing only; the server treats source/symbology as client-reported metadata. The E2E package signs in with its own callback (`io.qrun.mobile.scanner.e2e.oauth://callback/oauth2redirect`, see [AUTHENTICATION.md](AUTHENTICATION.md)), so a server that advertises only the normal scanner callback offers it no browser sign-in.
### Emulator test build under the normal application ID
For such a server, `:scanner-host:assembleEmu` builds **QQQ Mobile EMULATOR TEST**: the same fixture driver under the normal application ID `io.qrun.mobile.scanner`, so it signs in with the normal callback. Its version name ends in `-emu`. Send it the broadcast above with `-p io.qrun.mobile.scanner`.
- It is debug-signed, so it can never be installed beside or over the production-signed application. `adb uninstall io.qrun.mobile.scanner` must come first, which removes that installation's session and device enrollment; going back means uninstalling the test build and reinstalling the signed APK.
- It does not use the E2E manifest or network security config: cleartext stays off and only the system trust store is used.
- The fixture driver is created only when `android.os.Build` describes an SDK emulator image, and every signal must agree: `HARDWARE` is `goldfish` or `ranchu`, `PRODUCT` starts with `sdk_`, `MODEL` starts with `sdk_` or `Android SDK built for `, and `FINGERPRINT` names that same product. Anywhere else there is no driver and the scan control reports hardware scanning as unavailable. The disposable `.e2e` package is exempt and behaves as before.
- Never install it on a handheld. It is a local test aid, not a release artifact, and CI does not build it.
## Automated checks
@@ -132,7 +132,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
/*******************************************************************************
** Route sign-out through explicit process-exit confirmation so pending work is not silently abandoned.
*******************************************************************************/
fun requestSignOut() = requestLeave("Sign out?", "Sign out", alwaysConfirm = true) { qViewModel.requestLogout() }
fun requestSignOut(providerSignOut: com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding? = null) =
if (providerSignOut != null) requestCompanySignOut(providerSignOut)
else requestLeave("Sign out?", "Sign out", alwaysConfirm = true) { qViewModel.requestLogout() }
/*******************************************************************************
** The same guarded leave/cancel policy applies to explicitly enabled provider sign-out.
@@ -43,6 +43,13 @@ import kotlin.coroutines.resumeWithException
*******************************************************************************/
val LocalOAuth2Discovery = staticCompositionLocalOf<suspend (OAuth2LoginBinding) -> AuthorizationServiceConfiguration> { ::discoverOAuth2 }
/*******************************************************************************
** Sent with every interactive sign-in next to prompt=login. OIDC max_age=0 independently requires the provider to
** re-authenticate, so a provider session left in the browser cannot silently sign the next person in; the real
** Authentik test showed prompt=login alone reusing retained SSO (docs/AUTHENTICATION.md).
*******************************************************************************/
val OAUTH2_FORCE_REAUTH_PARAMETERS: Map<String, String> = mapOf("max_age" to "0")
/*******************************************************************************
** Browser authorization is standard AppAuth; API1.10 exchanges the code and validates the retained original nonce.
*******************************************************************************/
@@ -93,7 +100,8 @@ fun QOAuth2Login(viewModel: QViewModel, metadata: OAuth2AuthenticationMetaData,
val request = AuthorizationRequest.Builder(
configuration, binding.clientId, ResponseTypeValues.CODE,
Uri.parse(binding.redirectUri)
).setScope(binding.scopes).setPrompt(binding.prompt).setResponseMode("query").build()
).setScope(binding.scopes).setPrompt(binding.prompt).setAdditionalParameters(OAUTH2_FORCE_REAUTH_PARAMETERS)
.setResponseMode("query").build()
check(request.codeVerifierChallengeMethod == "S256")
if (viewModel.beginOAuth2Browser(
generation, binding, requireNotNull(request.state),
@@ -24,6 +24,7 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.oauth2LogoutBinding
/*******************************************************************************
@@ -34,10 +35,20 @@ val LocalOpenDiagnostics = compositionLocalOf<(() -> Unit)?> { null }
val LocalBusinessNavigationEnabled = compositionLocalOf { true }
/*******************************************************************************
** Expose account actions while keeping optional company sign-out separate from ordinary local sign-out.
** Expose account actions; sign-out also ends the provider session where the host and server support it.
*******************************************************************************/
/*******************************************************************************
** Sign-out ends the identity-provider session too whenever the host enables it and the server supports it;
** otherwise a session left in the browser silently signs the next sign-in back in. Null means local-only sign-out.
*******************************************************************************/
fun providerSignOutBinding(binding: OAuth2LogoutBinding?, serverSupportsProviderLogout: Boolean): OAuth2LogoutBinding? =
if (serverSupportsProviderLogout) binding else null
/*******************************************************************************
** Resolve the provider sign-out binding for the current server, or null when it is disabled or unsupported.
*******************************************************************************/
@Composable
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
fun rememberProviderSignOutBinding(model: QViewModel): OAuth2LogoutBinding? {
val companyLogout = if (LocalCompanySignOutEnabled.current)
((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let {
oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName)
@@ -53,6 +64,12 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
supportsCompanyLogout = false
}
}
return providerSignOutBinding(companyLogout, supportsCompanyLogout)
}
@Composable
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
val providerSignOut = rememberProviderSignOutBinding(model)
AlertDialog(
onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) },
text = {
@@ -60,13 +77,8 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
Text(if (model.isAuthenticated) "Current User: ${model.sessionUserFullName}" else "Not currently logged in")
TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = {
dismiss()
model.qNavigator.requestSignOut()
model.qNavigator.requestSignOut(providerSignOut)
}) { Text("Sign out") }
if (companyLogout != null && supportsCompanyLogout)
TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = {
dismiss()
model.qNavigator.requestCompanySignOut(companyLogout)
}) { Text("Sign out of company") }
}
}, confirmButton = { TextButton(onClick = dismiss) { Text("Close") } })
}
@@ -13,7 +13,9 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAu
import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAUTH2_FORCE_REAUTH_PARAMETERS
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LoginBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.providerSignOutBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase
import kotlinx.coroutines.Dispatchers
@@ -145,12 +147,60 @@ class QViewModelLogoutTest {
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName)
}
/*******************************************************************************
** After sign-out (ordinary or provider) a restarted app finds no stored session and does not resume one.
*******************************************************************************/
@Test
fun restartAfterSignOutDoesNotRestoreTheSession() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
for (provider in listOf(false, true)) {
val store = MockPreferencesDataStore()
val repository = LogoutRepository()
val model = signedInModel(repository, store)
assertEquals("session-1", store.preferences[com.kingsrook.qqq.frontend.android.mobileapp.data.SESSION_UUID_KEY])
if (provider) assertNotNull(companyLogout(model))
else { model.requestLogout(); model.logOut().join() }
assertNull(store.preferences[com.kingsrook.qqq.frontend.android.mobileapp.data.SESSION_UUID_KEY]?.takeIf { it.isNotEmpty() })
val restartedRepository = LogoutRepository()
val restarted = QViewModel(restartedRepository, store)
advanceUntilIdle()
assertFalse(restarted.isAuthenticated)
assertEquals("", restarted.sessionUUID)
assertEquals(0, restartedRepository.metaDataCalls)
}
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
** Sign-out reaches the identity provider whenever the server supports it, never silently local-only.
*******************************************************************************/
@Test
fun signOutUsesProviderLogoutWhenSupported() {
val binding = OAuth2LogoutBinding(loginBinding, REDIRECT)
assertEquals(binding, providerSignOutBinding(binding, true))
assertNull(providerSignOutBinding(binding, false))
assertNull(providerSignOutBinding(null, true))
}
/*******************************************************************************
** Every interactive sign-in forces re-authentication, so a lingering provider session cannot sign the next user in.
*******************************************************************************/
@Test
fun authorizationRequestForcesReauthentication() {
assertEquals(mapOf("max_age" to "0"), OAUTH2_FORCE_REAUTH_PARAMETERS)
assertEquals("login", loginBinding.prompt)
}
/*******************************************************************************
** Complete a native browser login against the fake repository so sign-out tests start authenticated.
*******************************************************************************/
private fun TestScope.signedInModel(repository: LogoutRepository): QViewModel {
private fun TestScope.signedInModel(repository: LogoutRepository, store: MockPreferencesDataStore = MockPreferencesDataStore()): QViewModel {
QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER))
val model = QViewModel(repository, MockPreferencesDataStore())
val model = QViewModel(repository, store)
advanceUntilIdle()
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
@@ -182,6 +232,7 @@ class QViewModelLogoutTest {
private class LogoutRepository : QQQRepository {
val calls = mutableListOf<String>()
var activeSession = ""
var metaDataCalls = 0
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData(
"oauth", values = OAuth2AuthenticationMetaData.Values(
@@ -214,7 +265,7 @@ class QViewModelLogoutTest {
override fun setEnvironment(environment: Environment) {}
override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Not used")
override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance = QInstance()
override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance { metaDataCalls++; return QInstance() }
override suspend fun getProcessMetaData(processName: String): QProcessMetaData = error("Not used")
override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
+14 -3
View File
@@ -13,7 +13,7 @@ aboutLibraries {
fetchRemoteLicense = false
fetchRemoteFunding = false
includePlatform = false
filterVariants = arrayOf("debug", "release", "e2e", "qa")
filterVariants = arrayOf("debug", "release", "e2e", "qa", "emu")
// The plugin also scans compile classpaths. This SDK is not redistributed;
// verifyEmdkSdk separately forbids it in every runtime classpath.
exclusionPatterns = listOf(Regex("^com\\.symbol:emdk$").toPattern())
@@ -26,7 +26,7 @@ aboutLibraries {
// changing a selected dependency can reuse the previous dependency_cache.json.
tasks.withType<com.mikepenz.aboutlibraries.plugin.AboutLibrariesCollectorTask>().configureEach {
inputs.property("resolvedDependencyCoordinates", providers.provider {
listOf("debug", "release", "e2e", "qa").flatMap { variant ->
listOf("debug", "release", "e2e", "qa", "emu").flatMap { variant ->
listOf("CompileClasspath", "RuntimeClasspath").flatMap { suffix ->
val configurationName = "$variant$suffix"
configurations.getByName(configurationName).incoming.resolutionResult.allComponents.map { component ->
@@ -89,11 +89,22 @@ android {
applicationIdSuffix = ".qa"
matchingFallbacks += "debug"
}
// Emulator-only test build: the E2E scan fixture under the normal application ID, so a server that
// registers only the normal sign-in callback accepts it. Debug-signed, so it can never update or sit
// beside the production-signed application, and its fixture driver refuses to exist off an emulator.
create("emu") {
initWith(getByName("debug"))
versionNameSuffix = "-emu"
matchingFallbacks += "debug"
}
}
testBuildType = scannerTestBuildType
sourceSets.getByName("debug").java.srcDir("src/production/java")
sourceSets.getByName("release").java.srcDir("src/production/java")
sourceSets.getByName("qa").java.srcDir("src/production/java")
// Only the fixture driver and its tests are shared; the E2E manifest and network security config are not.
sourceSets.getByName("emu").java.srcDir("src/e2e/java")
sourceSets.getByName("testEmu").java.srcDir("src/testE2e/java")
if(scannerTestBuildType == "qa") {
sourceSets.getByName("androidTestQa").java.srcDir("src/qaAndroidTest/java")
sourceSets.getByName("androidTestQa").manifest.srcFile("src/qaAndroidTest/AndroidManifest.xml")
@@ -148,7 +159,7 @@ val verifyEmdkSdk = tasks.register("verifyEmdkSdk") {
}.artifacts.artifacts
check(vendorRuntime.isEmpty()) { "Zebra EMDK must remain compile-only; found in ${runtime.name}" }
}
listOf("debug", "release", "e2e", "qa").forEach { variant ->
listOf("debug", "release", "e2e", "qa", "emu").forEach { variant ->
val artifacts = configurations.getByName("${variant}CompileClasspath").incoming.artifactView {
componentFilter {
it is org.gradle.api.artifacts.component.ModuleComponentIdentifier && it.group == "com.symbol" && it.module == "emdk"
@@ -5,16 +5,53 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.core.content.ContextCompat
import java.util.Base64
internal const val HARDWARE_SCAN_SOURCE = "e2e-fixture"
internal const val E2E_PACKAGE = "io.qrun.mobile.scanner.e2e"
internal const val E2E_RAW_DATA_EXTRA = "io.qrun.mobile.scanner.e2e.raw_data"
internal const val E2E_RAW_DATA_BASE64_EXTRA = "io.qrun.mobile.scanner.e2e.raw_data_base64"
internal const val E2E_AIM_IDENTIFIER_EXTRA = "io.qrun.mobile.scanner.e2e.aim_identifier"
/*******************************************************************************
** Disposable E2E package only. No debug/release source set contains this ingress.
** `adb shell am broadcast` cannot send a byte-array extra, so a fixture may send the same bytes as canonical
** padded Base64 text. The byte array wins when both are present; other text throws IllegalArgumentException.
*******************************************************************************/
internal fun createScannerDriver(context: Context): ScannerDriver = object : ScannerDriver {
internal fun e2eRawData(raw: ByteArray?, base64: String?): ByteArray? = raw ?: base64?.let { text ->
Base64.getDecoder().decode(text).also { require(Base64.getEncoder().encodeToString(it) == text) { "Raw data must be canonical padded Base64." } }
}
/*******************************************************************************
** An Android SDK emulator image, judged conservatively from android.os.Build: every signal must agree.
** The virtual board (goldfish/ranchu), an `sdk_` product, an SDK model name, and a fingerprint that names
** that same product. A real handheld fails the first test; one odd property never passes alone.
*******************************************************************************/
internal fun isAndroidEmulator(hardware: String?, product: String?, model: String?, fingerprint: String?): Boolean =
(hardware == "goldfish" || hardware == "ranchu") &&
product != null && product.startsWith("sdk_") &&
model != null && (model.startsWith("sdk_") || model.startsWith("Android SDK built for ")) &&
fingerprint != null && fingerprint.contains("/$product/")
/*******************************************************************************
** The disposable E2E package may take fixture scans anywhere, as before. Any other package that carries
** this source (the `emu` build type uses the normal application ID) may do so only on an emulator.
*******************************************************************************/
internal fun scanFixtureAllowed(packageName: String, hardware: String?, product: String?, model: String?, fingerprint: String?): Boolean =
packageName == E2E_PACKAGE || isAndroidEmulator(hardware, product, model, fingerprint)
/*******************************************************************************
** E2E and emulator test builds only. No debug/qa/release source set contains this ingress, and off the
** disposable E2E package there is no driver at all unless the device is an Android emulator.
*******************************************************************************/
internal fun createScannerDriver(context: Context): ScannerDriver? =
if (scanFixtureAllowed(context.packageName, Build.HARDWARE, Build.PRODUCT, Build.MODEL, Build.FINGERPRINT)) fixtureScannerDriver(context) else null
/*******************************************************************************
** Takes one broadcast per pending read, only while the scanner widget holds it open.
*******************************************************************************/
private fun fixtureScannerDriver(context: Context): ScannerDriver = object : ScannerDriver {
private var listener: ScannerDriver.Listener? = null
private var registered = false
private var pending = false
@@ -26,11 +63,17 @@ internal fun createScannerDriver(context: Context): ScannerDriver = object : Sca
if (!pending || intent?.action != "io.qrun.mobile.scanner.SCAN") return
val data = intent.getStringExtra("com.symbol.datawedge.data_string") ?: return
if (intent.getStringExtra("com.symbol.datawedge.decode_mode") == "multiple" || intent.hasExtra("com.symbol.datawedge.barcodes")) return
pending = false
// Test-only fixture metadata: exact bytes and an explicit AIM identifier, never derived from the text.
val raw = intent.getByteArrayExtra(E2E_RAW_DATA_EXTRA)
val aim = intent.getStringExtra(E2E_AIM_IDENTIFIER_EXTRA)
listener?.scanned(ScannerCapture.of(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN", raw, aim))
// Malformed metadata leaves the read pending, so the broadcast is ignored like any other invalid one.
val capture = try {
val raw = e2eRawData(intent.getByteArrayExtra(E2E_RAW_DATA_EXTRA), intent.getStringExtra(E2E_RAW_DATA_BASE64_EXTRA))
val aim = intent.getStringExtra(E2E_AIM_IDENTIFIER_EXTRA)
ScannerCapture.of(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN", raw, aim)
} catch (invalid: IllegalArgumentException) {
return
}
pending = false
listener?.scanned(capture)
listener?.idle()
}
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- Emulator-only test build under the normal application ID. Only the label differs from the main manifest:
cleartext stays off and there is no network security config, so HTTPS with system trust is the only transport. -->
<application android:label="QQQ Mobile EMULATOR TEST" tools:replace="android:label" />
</manifest>
@@ -30,6 +30,7 @@ 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 com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.rememberProviderSignOutBinding
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.SideEffect
@@ -405,6 +406,7 @@ private fun DeviceGate(
viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, onApproval: (Boolean) -> Unit,
content: @Composable () -> Unit
) {
val providerSignOut = rememberProviderSignOutBinding(viewModel)
var attempt by remember { mutableIntStateOf(0) }
var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") }
var error by remember { mutableStateOf("") }
@@ -444,6 +446,6 @@ private fun DeviceGate(
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") }
Button(onClick = { viewModel.qNavigator.requestSignOut(providerSignOut) }) { Text("Sign out") }
}
}
@@ -0,0 +1,46 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
/*******************************************************************************
** The E2E fixture's text alternative for raw bytes, which `adb shell am broadcast` can send.
*******************************************************************************/
class E2eRawDataTest {
private val framed = "]C101095011015300031120101421TEST000123"
/*******************************************************************************
** The decoded text reaches the capture as the same canonical Base64 the byte-array extra produces.
*******************************************************************************/
@Test
fun base64TextDecodesToTheExactBytes() {
val text = java.util.Base64.getEncoder().encodeToString(framed.toByteArray(Charsets.US_ASCII))
assertArrayEquals(framed.toByteArray(Charsets.US_ASCII), e2eRawData(null, text))
assertEquals(text, ScannerCapture.of(framed.substring(3), "LABEL-TYPE-EAN128", e2eRawData(null, text), "]C1").rawDataBase64)
assertArrayEquals(byteArrayOf(0x00, 0xFF.toByte(), 0x80.toByte()), e2eRawData(null, "AP+A"))
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun byteArrayWinsAndAbsenceStaysAbsent() {
assertArrayEquals(byteArrayOf(1, 2), e2eRawData(byteArrayOf(1, 2), "AP+A"))
assertArrayEquals(byteArrayOf(1, 2), e2eRawData(byteArrayOf(1, 2), "not base64"))
assertNull(e2eRawData(null, null))
}
/*******************************************************************************
** Anything but canonical padded Base64 is refused, so the driver ignores that broadcast.
*******************************************************************************/
@Test
fun malformedOrNonCanonicalTextIsRefused() {
for (text in listOf("not base64", "AP+A ", " AP+A", "AP-A", "QQ", "QQ=", "QR==", "AP+A\n")) {
assertThrows(text, IllegalArgumentException::class.java) { e2eRawData(null, text) }
}
}
}
@@ -0,0 +1,65 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/*******************************************************************************
** The fixture driver exists off the disposable E2E package only on an Android emulator.
*******************************************************************************/
class ScanFixtureGuardTest {
private val hardware = "ranchu"
private val product = "sdk_gphone64_x86_64"
private val model = "sdk_gphone64_x86_64"
private val fingerprint = "google/sdk_gphone64_x86_64/emu64xa:15/AE3A.240806.043/12960925:userdebug/dev-keys"
private val handheld = "Zebra/TC22/TC22:13/13-00-00.00-TG-U00-STD/000:user/release-keys"
/*******************************************************************************
** Values as an API 35 Google APIs emulator image reports them, and an older generic image.
*******************************************************************************/
@Test
fun sdkEmulatorImagesAreRecognised() {
assertTrue(isAndroidEmulator(hardware, product, model, fingerprint))
assertTrue(isAndroidEmulator("goldfish", "sdk_x86", "Android SDK built for x86", "generic/sdk_x86/generic_x86:9/PSR1.180720.075/5124027:userdebug/test-keys"))
}
/*******************************************************************************
** Fake handheld values: nothing about a real device may pass, whatever it is.
*******************************************************************************/
@Test
fun realHardwareIsRefused() {
assertFalse(isAndroidEmulator("qcom", "TC22", "TC22", handheld))
assertFalse(isAndroidEmulator("qcom", "MC9400", "MC9400", "Zebra/MC9400/MC9400:13/13-00-00.00-TG-U00-STD/000:user/release-keys"))
assertFalse(isAndroidEmulator(null, null, null, null))
assertFalse(isAndroidEmulator("", "", "", ""))
}
/*******************************************************************************
** Every signal is required; one emulator-looking property on otherwise real values proves nothing.
*******************************************************************************/
@Test
fun everySignalMustAgree() {
assertFalse(isAndroidEmulator("qcom", product, model, fingerprint))
assertFalse(isAndroidEmulator("ranchu2", product, model, fingerprint))
assertFalse(isAndroidEmulator(hardware, "TC22", model, fingerprint))
assertFalse(isAndroidEmulator(hardware, "sdk", model, fingerprint))
assertFalse(isAndroidEmulator(hardware, product, "TC22", fingerprint))
assertFalse(isAndroidEmulator(hardware, product, model, handheld))
assertFalse(isAndroidEmulator(hardware, product, model, "google/sdk_gphone64_arm64/emu64a:15/AE3A.240806.043/12960925:userdebug/dev-keys"))
assertFalse(isAndroidEmulator(hardware, product, model, null))
assertFalse(isAndroidEmulator("ranchu", "TC22", "TC22", handheld))
}
/*******************************************************************************
** The disposable E2E package keeps its behaviour; the normal application ID is emulator-only.
*******************************************************************************/
@Test
fun onlyTheE2ePackageIsExemptFromTheEmulatorRule() {
assertTrue(scanFixtureAllowed("io.qrun.mobile.scanner.e2e", "qcom", "TC22", "TC22", handheld))
assertFalse(scanFixtureAllowed("io.qrun.mobile.scanner", "qcom", "TC22", "TC22", handheld))
assertFalse(scanFixtureAllowed("io.qrun.mobile.scanner.qa", "qcom", "TC22", "TC22", handheld))
assertFalse(scanFixtureAllowed("io.qrun.mobile.scanner.e2e.other", "qcom", "TC22", "TC22", handheld))
assertTrue(scanFixtureAllowed("io.qrun.mobile.scanner", hardware, product, model, fingerprint))
}
}