Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0419cdea91
|
||
|
|
d5c3ac2d6a
|
||
|
|
9bae976138
|
||
|
|
b87171cafc
|
||
|
|
3b15183f52
|
||
|
|
b165dd34a8
|
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
+9
-1
@@ -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),
|
||||
|
||||
+20
-8
@@ -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") } })
|
||||
}
|
||||
|
||||
+54
-3
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user