From 625a275de334ac4bfaba50bbd0dc7afb861cc243 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 11:38:09 -0600 Subject: [PATCH] fix(quality): apply client standards review and add Query About Preserve ownership notices, replace wildcard imports, document source contracts, enforce source checks and strict Kotlin warnings, and apply the repository native formatter policy. Correct typed widget state and callback registration with regression coverage. Add bundled Query artwork, public Dogfooding and Query links, version and license access under Settings About. Validation:163 JVM cases,46 TC22 sample native cases, QA compilation, APK builds and lint0errors/0warnings. Full formatter CI and actual external-provider Android acceptance remain explicit. Co-Authored-By: Codex --- .github/workflows/scanner.yml | 2 + .idea/codeStyles/Project.xml | 15 + .idea/codeStyles/codeStyleConfig.xml | 5 + NOTICE | 2 + README.md | 2 + build.gradle.kts | 32 + docs/CODING-STANDARDS-REVIEW-2026-11-01.md | 48 ++ docs/LEGACY-SOURCE-FOLLOWUPS.md | 18 + docs/SESSION-STATE.md | 8 + docs/TODO.md | 6 + qqq/android/core/build.gradle.kts | 21 + .../android/core/ExampleInstrumentedTest.kt | 23 +- .../core/controllers/NetworkQQQRepository.kt | 250 ++++--- .../android/core/controllers/QQQApiService.kt | 81 ++- .../android/core/controllers/QQQRepository.kt | 85 ++- .../controllers/RequestOutcomeInterceptor.kt | 31 +- .../core/controllers/UiApiCompatibility.kt | 97 ++- .../core/controllers/UiApiException.kt | 56 +- .../android/core/model/Environment.kt | 11 +- .../android/core/model/GenericSerializing.kt | 112 +++- .../android/core/model/QPossibleValue.kt | 9 +- .../core/model/metadata/AdornmentType.kt | 23 +- .../core/model/metadata/FieldAdornment.kt | 9 +- .../core/model/metadata/QAppMetaData.kt | 10 +- .../core/model/metadata/QAppNodeType.kt | 23 +- .../core/model/metadata/QAppSection.kt | 20 +- .../core/model/metadata/QAppTreeNode.kt | 9 +- .../core/model/metadata/QBrandingMetaData.kt | 9 +- .../core/model/metadata/QComponentType.kt | 23 +- .../core/model/metadata/QFieldMetaData.kt | 13 +- .../android/core/model/metadata/QFieldType.kt | 23 +- .../core/model/metadata/QFrontendComponent.kt | 6 +- .../model/metadata/QFrontendStepMetaData.kt | 9 +- .../core/model/metadata/QHelpContent.kt | 9 +- .../android/core/model/metadata/QIcon.kt | 9 +- .../android/core/model/metadata/QInstance.kt | 12 +- .../core/model/metadata/QProcessMetaData.kt | 11 +- .../model/metadata/QProcessMetaDataWrapped.kt | 11 +- .../core/model/metadata/QTableMetaData.kt | 12 +- .../core/model/metadata/QWidgetMetaData.kt | 12 +- .../Auth0AuthenticationMetaData.kt | 18 +- .../AuthenticationSerializer.kt | 20 +- .../BaseAuthenticationMetaData.kt | 9 +- .../FullyAnonymousAuthenticationMetaData.kt | 17 +- .../authentication/ManageSessionRequest.kt | 16 +- .../authentication/ManageSessionResponse.kt | 9 +- .../metadata/authentication/NativeLogout.kt | 21 +- .../OAuth2AuthenticationMetaData.kt | 34 +- .../TableBasedAuthenticationMetaData.kt | 13 +- .../processes/ProcessMetaDataAdjustment.kt | 26 +- .../core/model/processes/ProcessStepResult.kt | 31 +- .../processes/ProcessStepResultSerializer.kt | 17 +- .../core/model/processes/QJobComplete.kt | 26 +- .../android/core/model/processes/QJobError.kt | 26 +- .../core/model/processes/QJobRunning.kt | 26 +- .../core/model/processes/QJobStarted.kt | 26 +- .../android/core/model/widgets/WidgetBlock.kt | 68 +- .../android/core/utils/StringExtensions.kt | 10 +- .../controllers/DeferredAuthenticationTest.kt | 128 ++-- .../controllers/NativeLogoutRepositoryTest.kt | 94 ++- .../controllers/NativeSessionLoginTest.kt | 63 +- .../controllers/NetworkQQQRepositoryTest.kt | 30 +- .../controllers/UiApiCompatibilityTest.kt | 80 ++- .../core/model/GenericSerializingTest.kt | 70 +- .../android/core/model/MobileProtocolTest.kt | 29 +- .../core/model/metadata/QAppSectionTest.kt | 20 +- .../AuthenticationSerializerTest.kt | 22 +- .../core/model/widgets/WidgetBlockTest.kt | 49 +- .../core/utils/StringExtensionsTest.kt | 17 +- qqq/android/mobileapp/build.gradle.kts | 21 + .../mobileapp/ExampleInstrumentedTest.kt | 23 +- .../android/mobileapp/QMobileAppMain.kt | 133 ++-- .../android/mobileapp/QMobileApplication.kt | 19 +- .../mobileapp/container/QAppContainer.kt | 17 +- .../container/QDefaultAppContainer.kt | 11 +- .../data/MockPreferencesDataStore.kt | 22 +- .../mobileapp/data/SettingsRepository.kt | 62 +- .../android/mobileapp/ui/HostExtensions.kt | 16 +- .../android/mobileapp/ui/QNavigator.kt | 166 ++--- .../android/mobileapp/ui/apps/QAppHome.kt | 10 +- .../mobileapp/ui/apps/QAppHomeGridLayout.kt | 64 +- .../ui/authentication/Auth0Browser.kt | 34 +- .../ui/authentication/OAuth2LoginAttempt.kt | 86 ++- .../ui/authentication/OAuth2LogoutAttempt.kt | 61 +- .../authentication/OAuth2RedirectActivity.kt | 19 +- .../ui/authentication/QAuth0Login.kt | 49 +- .../ui/authentication/QAuth0Logout.kt | 38 +- .../ui/authentication/QAuth0Service.kt | 30 +- .../authentication/QAuthenticationWrapper.kt | 19 +- .../mobileapp/ui/authentication/QLoginForm.kt | 20 +- .../ui/authentication/QLogoutForm.kt | 20 +- .../ui/authentication/QOAuth2Login.kt | 95 +-- .../ui/authentication/QOAuth2Logout.kt | 52 +- .../ui/authentication/QPasswordLogin.kt | 24 +- .../mobileapp/ui/common/FullScreenLoading.kt | 11 +- .../ui/common/FullSizedAllCenteredColumn.kt | 14 +- .../mobileapp/ui/horseshoe/MenuDialog.kt | 45 +- .../ui/horseshoe/NavigationSurfaces.kt | 100 +-- .../mobileapp/ui/horseshoe/NetworkPresence.kt | 19 +- .../mobileapp/ui/horseshoe/NoNetworkDialog.kt | 10 +- .../ui/horseshoe/QApplicationWrapper.kt | 44 +- .../ui/horseshoe/QNavigationDrawer.kt | 82 ++- .../mobileapp/ui/horseshoe/QTopAppBar.kt | 49 +- .../ui/processes/FrontendStepFormat.kt | 84 +-- .../ui/processes/ProcessBottomButtons.kt | 34 +- .../mobileapp/ui/processes/ProcessSound.kt | 50 +- .../ui/processes/ProcessStepLoading.kt | 29 +- .../mobileapp/ui/processes/QProcessHome.kt | 107 ++- .../ui/processes/components/PlainText.kt | 4 +- .../components/ProcessComponentEditForm.kt | 66 +- .../components/ProcessComponentHTML.kt | 19 +- .../components/ProcessComponentHelpText.kt | 10 +- .../ProcessComponentProcessSummaryResults.kt | 8 +- .../components/ProcessComponentWidget.kt | 115 +--- .../components/UnsupportedProcessComponent.kt | 30 +- .../mobileapp/ui/utils/BlockStyleUtils.kt | 135 ++-- .../android/mobileapp/ui/utils/Colors.kt | 10 +- .../mobileapp/ui/utils/DrawableUtils.kt | 29 +- .../android/mobileapp/ui/utils/Dropdown.kt | 27 +- .../android/mobileapp/ui/utils/DynamicIcon.kt | 30 +- .../mobileapp/ui/utils/LoadStateView.kt | 22 +- .../android/mobileapp/ui/utils/Reloader.kt | 8 +- .../ui/widgets/blocks/AudioWidgetBlock.kt | 64 +- .../ui/widgets/blocks/ButtonWidgetBlock.kt | 56 +- .../ui/widgets/blocks/CompositeWidgetBlock.kt | 86 ++- .../ui/widgets/blocks/DividerWidgetBlock.kt | 10 +- .../ui/widgets/blocks/ImageWidgetBlock.kt | 11 +- .../widgets/blocks/InputFieldWidgetBlock.kt | 49 +- .../ui/widgets/blocks/TextWidgetBlock.kt | 63 +- .../widgets/blocks/WidgetBlockComposable.kt | 14 +- .../widgets/blocks/WidgetBlockParameters.kt | 10 +- .../android/mobileapp/viewmodel/LoadState.kt | 73 +-- .../viewmodel/ProcessAcknowledgment.kt | 11 +- .../mobileapp/viewmodel/ProcessViewModel.kt | 477 ++++++-------- .../android/mobileapp/viewmodel/QViewModel.kt | 617 +++++++++--------- .../mobileapp/viewmodel/SemanticFields.kt | 55 +- .../mobileapp/viewmodel/SemanticProcess.kt | 108 +-- .../mobileapp/viewmodel/TopLevelAppState.kt | 48 +- .../android/mobileapp/ExampleUnitTest.kt | 23 +- .../authentication/OAuth2LoginAttemptTest.kt | 128 ++-- .../authentication/OAuth2LogoutAttemptTest.kt | 99 ++- .../ui/horseshoe/NetworkPresenceTest.kt | 39 +- .../ui/processes/ProcessSoundTest.kt | 61 +- .../mobileapp/ui/utils/BlockStyleUtilsTest.kt | 38 +- .../viewmodel/ProcessSubmissionTest.kt | 341 +++++++--- .../viewmodel/SemanticProcessTest.kt | 206 ++++-- qqqsampleandroidmobileapp/build.gradle.kts | 1 + .../AuthenticationLifecycleTests.kt | 232 +++++-- .../qqq/sampleandroidmobileapp/BaseTest.kt | 15 +- .../CoilRenderingTests.kt | 110 ++-- .../sampleandroidmobileapp/NavigationTests.kt | 176 +++-- .../OAuth2LoginTests.kt | 239 ++++--- .../OAuth2LogoutTests.kt | 169 +++-- .../sampleandroidmobileapp/ProcessTests.kt | 24 +- .../SampleAppMockQQQRepository.kt | 101 ++- .../SampleHostSecurityTests.kt | 17 +- .../SemanticInputTests.kt | 97 ++- .../sampleandroidmobileapp/SessionTests.kt | 78 +-- .../previews/apps/QAppHomePreview.kt | 32 +- .../previews/horseshoe/MenuDialogPreview.kt | 24 +- .../previews/horseshoe/QTopAppBarPreview.kt | 8 +- .../processes/ProcessStepLoadingPreview.kt | 29 +- .../components/ProcessComponentHTMLPreview.kt | 8 +- .../ProcessComponentWidgetPreview.kt | 115 ++-- .../UnsupportedProcessComponentPreview.kt | 8 +- .../previews/utils/PreviewUtils.kt | 17 +- .../blocks/ButtonWidgetBlockPreview.kt | 75 +-- .../blocks/CompositeWidgetBlockPreview.kt | 54 +- .../blocks/InputFieldWidgetBlockPreview.kt | 8 +- .../widgets/blocks/TextWidgetBlockPreview.kt | 167 +++-- .../sampleandroidmobileapp/utils/TestUtils.kt | 115 ++-- .../SampleAppMainActivity.kt | 19 +- .../sampleandroidmobileapp/ui/theme/Color.kt | 3 +- .../sampleandroidmobileapp/ui/theme/Theme.kt | 17 +- .../sampleandroidmobileapp/ui/theme/Type.kt | 3 +- .../sampleandroidmobileapp/ExampleUnitTest.kt | 23 +- scanner-host/build.gradle.kts | 1 + .../mobile/scanner/AppScreenCaptureTest.kt | 26 +- .../mobile/scanner/DiagnosticScreenTest.kt | 16 +- .../mobile/scanner/OpenSourceLicensesTest.kt | 17 +- .../mobile/scanner/OrientationPolicyTest.kt | 28 +- .../mobile/scanner/ScannerEndToEndTest.kt | 199 +++--- .../mobile/scanner/SessionAcceptanceClient.kt | 89 ++- .../mobile/scanner/ScannerDriverFactory.kt | 58 +- .../io/qrun/mobile/scanner/AboutScreen.kt | 94 +++ .../qrun/mobile/scanner/AppScreenCapture.kt | 29 +- .../java/io/qrun/mobile/scanner/DeviceApi.kt | 30 +- .../qrun/mobile/scanner/DeviceCredentials.kt | 67 +- .../scanner/DeviceScreenCaptureService.kt | 232 +++++-- .../mobile/scanner/DeviceScreenSharing.kt | 156 +++-- .../mobile/scanner/DiagnosticPresentation.kt | 49 +- .../qrun/mobile/scanner/DiagnosticScreen.kt | 91 ++- .../qrun/mobile/scanner/ForegroundLocation.kt | 123 ++-- .../java/io/qrun/mobile/scanner/HostChrome.kt | 98 ++- .../io/qrun/mobile/scanner/HostRuntime.kt | 153 +++-- .../qrun/mobile/scanner/LatestScreenImage.kt | 22 +- .../io/qrun/mobile/scanner/LocationPolicy.kt | 19 +- .../qrun/mobile/scanner/MobileSessionApi.kt | 85 ++- .../mobile/scanner/MobileSessionController.kt | 151 +++-- .../qrun/mobile/scanner/OpenSourceLicenses.kt | 10 +- .../qrun/mobile/scanner/OrientationPolicy.kt | 40 +- .../mobile/scanner/PhotoUploadViewModel.kt | 251 +++---- .../io/qrun/mobile/scanner/RecordFilesApi.kt | 231 ++++--- .../qrun/mobile/scanner/RecordPhotoWidget.kt | 57 +- .../java/io/qrun/mobile/scanner/ScanEvent.kt | 11 +- .../io/qrun/mobile/scanner/ScannerActivity.kt | 209 +++--- .../mobile/scanner/ScannerInputSession.kt | 208 ++++-- .../io/qrun/mobile/scanner/ScannerMetaData.kt | 30 +- .../io/qrun/mobile/scanner/ScannerWidget.kt | 78 ++- .../mobile/scanner/ScreenConsentAttempt.kt | 35 +- .../io/qrun/mobile/scanner/SessionHostUi.kt | 33 +- .../io/qrun/mobile/scanner/SessionScreen.kt | 90 ++- .../qrun/mobile/scanner/ZebraScannerDriver.kt | 179 +++-- .../main/res/drawable-nodpi/query_mascot.png | Bin 0 -> 9539 bytes .../mobile/scanner/ScannerDriverFactory.kt | 28 +- .../scanner/DeviceScreenAcceptanceTest.kt | 209 ++++-- .../mobile/scanner/ScannerInjectionTest.kt | 208 +++--- .../qrun/mobile/scanner/ScannerOverlayTest.kt | 238 +++++-- .../scanner/DiagnosticPresentationTest.kt | 42 +- .../io/qrun/mobile/scanner/HostRuntimeTest.kt | 79 ++- .../mobile/scanner/LatestScreenImageTest.kt | 35 +- .../qrun/mobile/scanner/LocationPolicyTest.kt | 34 +- .../qrun/mobile/scanner/MobileSessionTest.kt | 183 ++++-- .../mobile/scanner/OrientationPolicyTest.kt | 43 +- .../scanner/PhotoUploadViewModelTest.kt | 172 +++-- .../qrun/mobile/scanner/RecordFilesApiTest.kt | 91 ++- .../io/qrun/mobile/scanner/ScanEventTest.kt | 14 +- .../mobile/scanner/ScannerInputSessionTest.kt | 206 ++++-- .../mobile/scanner/ScannerMetaDataTest.kt | 27 +- .../scanner/ScreenConsentAttemptTest.kt | 30 +- .../io/qrun/mobile/scanner/ServerUrlTest.kt | 20 +- .../third-party/licenses/QQQ-Android.json | 2 +- scanner-security-probe/build.gradle.kts | 1 + .../scanner/probe/ScanProbeReceiver.java | 9 +- scripts/ci/check-source-standards.mjs | 79 +++ settings.gradle.kts | 21 + 236 files changed, 8736 insertions(+), 5485 deletions(-) create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 docs/CODING-STANDARDS-REVIEW-2026-11-01.md create mode 100644 docs/LEGACY-SOURCE-FOLLOWUPS.md create mode 100644 scanner-host/src/main/java/io/qrun/mobile/scanner/AboutScreen.kt create mode 100644 scanner-host/src/main/res/drawable-nodpi/query_mascot.png create mode 100644 scripts/ci/check-source-standards.mjs diff --git a/.github/workflows/scanner.yml b/.github/workflows/scanner.yml index d7be2a2..9b89f1e 100644 --- a/.github/workflows/scanner.yml +++ b/.github/workflows/scanner.yml @@ -35,6 +35,8 @@ jobs: - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 with: cache-read-only: ${{ github.event_name == 'pull_request' }} + - name: Verify source headers, whitespace, imports and JavaScript syntax + run: node scripts/ci/check-source-standards.mjs - name: Install required Android SDK packages run: | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools" diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..1f2f20d --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,15 @@ + + + diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..31c7460 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + diff --git a/NOTICE b/NOTICE index 34a421d..066d4ea 100644 --- a/NOTICE +++ b/NOTICE @@ -37,3 +37,5 @@ QQQ, qBits, qRun, qStudio, and the QRunIO logo are trademarks of QRunIO, LLC. Use of these trademarks must comply with QRunIO's Trademark Guidelines. ================================================================================ + +Query mascot artwork: QRun's existing Query puppy asset, sourced from marketing-website/assets/Puppy Mark/exports/PNG/qqq-puppy.png. Bundled for the About screen; the original artwork is unchanged. diff --git a/README.md b/README.md index ef6f685..0b4484b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ The persistent footer shows **Checking**, **Connected**, or **Disconnected** wit QQQ administrators can inspect session activity, end sessions, and request live viewing of the QQQ app from a session record. Viewing displays an indicator with **Stop viewing** on the handheld. Password/login screens and background apps cannot be mirrored. Foreground location reporting defaults on and can be disabled in Settings. It requires Android permission and enabled device location providers; scanning works without it. See [session behavior and limits](docs/UI-API.md#session-activity-and-screen-viewing). +Open **Settings → About QQQ Mobile** for the app version, Query mascot, [Dogfooding](https://www.qrun.io/dogfooding), [Query](https://www.qrun.io/query) and bundled open-source licenses. Public website links open in the external browser without adding session or device information. + There is no offline queue. One submitted scan is retained in memory until a complete receipt returns the matching `acknowledgedEventId`, `scanSaved: true`, and a nonblank `scanId`. If a response is uncertain, **Retry same request** preserves its event ID and original timestamp; the server deduplicates it. Further scans wait for confirmation or explicit abandonment. Leaving after an uncertain response warns that the scan may already be saved and attempts server cancellation; if cancellation fails, **Leave anyway** lets the operator exit explicitly. Active requests and pending photos remain protected. Sign-out/reset clears the pending request; app termination loses in-memory state. Repeated physical scans deliberately receive distinct event IDs. Enrollment credentials are encrypted with Android Keystore and stored under `noBackupFilesDir`, scoped to the configured server. The app disables backup. Device headers accompany native QQQ requests, and the server enforces approval on every scan. Release builds reject HTTP; only the development build permits it for local testing. The host disables HTTP redirects to avoid forwarding credentials to another origin. diff --git a/build.gradle.kts b/build.gradle.kts index 3aa9858..955e2aa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false @@ -6,3 +27,14 @@ plugins { alias(libs.plugins.aboutlibraries) apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0" apply false } + + +/******************************************************************************* + ** Keep every owned Kotlin variant and test compiler under the same warning + ** policy; compatibility suppressions belong on the specific affected source. + *******************************************************************************/ +subprojects { + tasks.withType().configureEach { + kotlinOptions.allWarningsAsErrors = true + } +} diff --git a/docs/CODING-STANDARDS-REVIEW-2026-11-01.md b/docs/CODING-STANDARDS-REVIEW-2026-11-01.md new file mode 100644 index 0000000..2e1fe15 --- /dev/null +++ b/docs/CODING-STANDARDS-REVIEW-2026-11-01.md @@ -0,0 +1,48 @@ +# Coding standards review — November 1, 2026 + +The scanner client received source-wide mechanical corrections and an explicit native Kotlin formatting pass. Compiler warnings now fail Kotlin compilation. The combined strict build passed with 163 JVM tests and no Kotlin warning/error diagnostics. Android lint reported zero errors, one bitmap-density warning and two informational findings; the density correction and its verification remain pending. These checks do not establish runtime acceptance or complete standards compliance. + +## Scope and ownership + +The review inventoried the original 2,524 tracked paths and all maintained working-tree source. The final mechanical gate covers 234 source/build/tooling files: 216 Kotlin sources, seven Gradle Kotlin files, nine JavaScript modules, one Java security probe and one Bash script. This includes shared core/mobileapp libraries, scanner host, sample application, source-set drivers, unit/native tests and CI scripts. The new About screen is included; its literal text and the Dogfooding/Query destinations are preserved by the formatter verification. + +The 2,142 Material Design drawable resources retain their upstream attribution and provenance in NOTICE and the material-icons manifest. Binary art, upstream wrapper implementation, third-party license data and generated/ignored build output were excluded from ownership rewrites. Existing Kingsrook and QRunIO copyright dates/notices were preserved. New comments do not replace third-party ownership. + +Static checks cover the entire maintained executable-source inventory. Named declaration-header correction covers all 216 Kotlin files and the Java probe. The declaration scanner excludes implicit property accessors, secondary constructors and anonymous objects; its counts are not a claim that every Kotlin syntax form was parsed. Manual semantic inspection concentrated on authentication/session boundaries, scanner ownership and release, capture consent/leases, transport/resource handling, widget conversion and callback registration. This is not an exhaustive semantic audit of every unchanged statement or every test assertion. + +## Implemented corrections + +- Added missing copyright/SPDX headers without replacing existing notices; corrected LF/final newlines and trailing whitespace. +- Replaced all 46 original wildcard imports with explicit symbols, including Compose delegation and coroutine-test extensions. +- Added useful ownership, cancellation, transport and contract comments to missing production declarations. Descriptive tests also receive the visual headers explicitly permitted by K22-CODE-09 and the QQQ code-style guidance. +- Added the [source standards gate](../scripts/ci/check-source-standards.mjs) to the existing Scanner Android verification job. It checks copyright/SPDX, UTF-8, LF/final newlines, trailing whitespace/tabs, Kotlin/Java wildcard imports and JavaScript syntax. The inventory includes tracked and nonignored untracked source, so the gate checks its own new file locally as well as in CI. +- Enabled `allWarningsAsErrors` for every `KotlinCompile` task across modules, variants and tests. No global warning suppression was added. +- Replaced unchecked widget-map casts with validated string-key copies into owned mutable state. Added regressions for immutable/shared-input ownership and malformed keys. +- Replaced the unchecked callback-function cast with checked zero-argument function registration and a return-discarding wrapper. Added regressions for malformed registration and once-per-step execution. Removed two proven redundant non-null assertions. +- Migrated the host to the already available lifecycle-compose lifecycle owner, removed a shadowed projection-listener name and retained deprecated accessibility-node recycling only on Android 26–32 with an expression-level compatibility explanation. +- Routed previously unused modifiers to the corresponding existing UI/error nodes. Five intentionally unused published renderer/dialog parameters retain their compatible signatures with parameter-level suppressions and specific explanations. These suppressions do not disable diagnostics elsewhere. +- Implemented the sample repository's previously unimplemented URI resolver with the existing production HTTP(S)/host/no-userinfo validation, and removed obsolete commented implementations. Unfinished metadata/renderer questions remain explicitly tracked in [legacy source follow-ups](LEGACY-SOURCE-FOLLOWUPS.md) and the [task list](TODO.md); deleting inline work markers does not implement those features. + +## Applicable formatter and precise limitation + +The existing repository sets `kotlin.code.style=official` in [gradle.properties](../gradle.properties), while [.editorconfig](../.editorconfig) requires three spaces. The shared personal style entry point and K22 standard give repository formatter configuration precedence. Kotlin therefore uses the native official structural layout with three-space indentation, rather than imposing incompatible generic Allman/control-parenthesis rules. The shared K22 no-line-length-limit rule is retained. The equivalent choice is now recorded in the [project IDE profile](../.idea/codeStyles/Project.xml) and its per-project selector. + +The installed Android Studio Rabbit 2 / 2026.2.2 formatter processed isolated copies of all 223 Kotlin/KTS files. Literal-aware comparison, including nested string templates and raw literals, found identical executable tokens and literal contents for all 223. The stable output changed 202 active files. A second native dry run reported 222 files well formed and **`scanner-host/build.gradle.kts` still needing reformatting**. Its original active content was retained instead of chasing repeated formatter churn. The tool returned exit code zero even when reporting files that needed reformatting, so exit status alone is insufficient for a formatter gate. + +The current GitHub workflow installs the Android SDK but does not declare an Android Studio IDE formatter installation. The existing mechanical CI gate is active; a complete native formatter CI gate requires a runner/image supplying the same existing IDE formatter and a wrapper that treats `Needs reformatting` as failure. No optional check is silently reported as a passing full formatter gate, and no alternate formatter framework was introduced. + +Relevant shared requirements: K22-CODE-01/02 (gates/warnings), 03–09 (layout and headers), 11/12 (inactive code and tracked work), 14 (imports), 20–22 (errors/private data/input validation), 24/25 (license headers/generated exclusions) and 26 (published API documentation). The remaining formatter file/CI provisioning and parser/semantic-review boundaries prevent claiming every standard is fully proven. + +## Verification for the current cohort + +- Source standards gate: **234 files passed**, including the new About screen and gate itself. +- JavaScript syntax: all nine maintained modules passed without executing their device/server operations. +- Native formatter comparison: **223/223 executable-token and literal checks passed**; 222/223 dry-run reports well formed, with the exact retained script exception above. +- Combined strict build: **passed** in 1 minute 11 seconds. Actual JVM reports contain **39 core + 53 mobileapp + 71 scanner-host = 163 tests**, with zero failures or skips. All four new warning-fix regressions are included. No Kotlin `w:`/`e:` diagnostics were emitted under warnings-as-errors. +- Scanner debug Android lint: **zero errors, one warning, two informational findings**. `IconLocation` identifies the new `query_mascot.png` in `res/drawable`; moving the unchanged image to `drawable-nodpi` and rerunning lint/debug assembly are pending. The informational findings recommend specialized integer state holders. +- Existing environment/toolchain advisories remain: SDK XML version mismatch, Java 21 warnings about source/target 8 configuration, and deprecated Gradle features. They are distinct from Kotlin source warnings and are not represented as a wholly warning-free build. +- QA/native-device verification is a separate coordinating run and remains pending in this report. JVM/build success must not be substituted for its result. + +No provider, physical scanner, projection, GPS or release acceptance gate is closed by this standards work. Those results remain in their existing readiness/acceptance records. + +Final follow-up: the unchanged Query bitmap now lives in drawable-nodpi; debug APK rebuild and lint pass with zero errors and zero warnings (two informational findings remain). All46 sample native tests pass on TC22 with no failures/errors/skips, and QA instrumentation compiles. Toolchain advisories and the formatter CI limitation above remain explicit. diff --git a/docs/LEGACY-SOURCE-FOLLOWUPS.md b/docs/LEGACY-SOURCE-FOLLOWUPS.md new file mode 100644 index 0000000..02f0ebd --- /dev/null +++ b/docs/LEGACY-SOURCE-FOLLOWUPS.md @@ -0,0 +1,18 @@ +# Legacy source follow-ups + +The standards cleanup removes speculative inline work markers and obsolete commented-out implementations. This checklist preserves the unfinished questions in repository task tracking. None of the entries below is represented as an implemented feature or a newly reproduced defect. Changes to supported semantics require their own contract and acceptance coverage. + +- [ ] `QAppMetaData`: determine whether supported native consumers require `supplementalAppMetaData`; define a typed contract and fixtures before adding it. +- [ ] `QFieldMetaData`: inventory field behavior metadata actually needed by native controls; avoid an untyped `List` placeholder. +- [ ] `QInstance`: define native report support and a typed report contract before exposing report metadata. +- [ ] `QTableMetaData`: inventory omitted table fields against the supported semantic UI API and add required fields with sanitized real fixtures. +- [ ] `QWidgetMetaData`: inventory omitted widget fields against supported native widgets and add required fields with fixtures. +- [ ] `ProcessComponentHelpText`: decide whether preview/expand behavior is part of the supported contract; test it if introduced. +- [ ] `ProcessComponentWidget.buildProcessComponentWidgetState`: define metadata-driven header/body/footer placement before restoring any list-position heuristic. The deleted implementation was inactive. +- [ ] `ProcessComponentWidget.evaluateWidgetBlocks`: reconcile legacy `INPUT_FIELD` value propagation with the native input renderer; do not restore commented web-client code without contract coverage. +- [ ] `CompositeWidgetBlock.Modal.controlCallback`: confirm whether the semantic callback means toggle or an explicit target visibility; test repeated callbacks against that contract. +- [ ] `ProcessViewModel.closeProcessCallback`: review current guarded navigation and cancellation coverage before changing reverse-navigation notification. The old comment predates the guarded navigation implementation and does not establish a current failure. +- [ ] `ProcessViewModel` widget event handling: verify permitted action-code validation against server metadata; define a fail-closed native check if the supported contract requires it. +- [ ] `QViewModel.loadQInstance`: derive frontend name/version from the declared client build identity instead of the existing fixed identification strings, with compatibility request coverage. + +The removed alternate network return and speculative session-test wait snippet are obsolete inactive implementations, retained in Git history rather than executable source. Their deletion changes no runtime behavior. Runtime, provider and physical-device acceptance gates remain tracked in the main task list and readiness documents. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 8ff99b6..ce1725a 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,13 @@ # Session state +## November 1 standards fixes and About update + +Applied the scoped standards review: copyright/SPDX, explicit imports, declaration headers, native official/three-space formatter profile, mechanical CI source gate and Kotlin warnings-as-errors. Strict compilation and163 JVM tests pass; all46 disposable sample native tests pass on TC22, and production QA instrumentation compiles. The native suite uses fixture providers; actual Android external-browser/provider acceptance remains separate. The existing formatter reports one unstable Gradle script and full formatter CI runner provisioning remains explicit in the review. + +Settings → About QQQ Mobile now shows bundled Query artwork, app version, Dogfooding/Query public links and open-source licenses. The normal APK rebuild uses drawable-nodpi for the artwork. No credential/device parameters are added to website links. The preceding diagnostics/default-on GPS update remains installed until the preserving update recorded below; actual GPS fix is still open. + +The real API1.10 Authentik desktop sequence now passes negative nonce rejection, A login, explicit company logout, old-cookie rejection, fresh B credentials/principal in the same retained browser context, and B local logout. Its provider-specific User Logout flow is required for company sign-out; default ordinary client logout stays local. Server0b53f34 is published and all8hosted checks pass. CorePR1369's exact architecture allow-list correction is being verified; full fresh core CI is not yet green. + ## November 1 diagnostics and location update Diagnostics now uses a full-screen, scrollable native view with a connection summary, local-time newest-first activity, All/Problems filters, pause/resume, copy/share/clear and Back. Existing footer and menu entry points still block process input while diagnostics is open. Location reporting defaults on for approved signed-in foreground sessions; Android grants remain required, explicit opt-out persists and automatic permission requests are not repeated after denial. Controls remain under Settings → Device permissions. diff --git a/docs/TODO.md b/docs/TODO.md index 08d542b..0fa00f1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,11 @@ # TODO +- [x] Apply scoped copyright/import/header/formatter corrections, mechanical CI gate and Kotlin warnings-as-errors;163JVM plus46TC22 sample native cases pass. See [review](CODING-STANDARDS-REVIEW-2026-11-01.md) for the remaining formatter CI limitation. +- [x] Add Query artwork, app version, Dogfooding/Query links and license access under Settings → About QQQ Mobile. +- [x] Qualify real Authentik desktop nonce rejection and retained-browser A → company logout → fresh B; actual Android callback acceptance remains separate. + +- [ ] Resolve the explicitly retained [legacy source follow-ups](LEGACY-SOURCE-FOLLOWUPS.md): metadata typing, renderer contracts, navigation/action validation and build identity. Removing inline work markers does not implement these features. + - [x] Replace the raw diagnostics popup with tested full-screen status/activity, filtering, pause, copy/share/clear and Back; 159 client JVM tests, zero-warning lint/build and the focused TC22 diagnostics UI test pass. - [x] Default foreground location reporting on, preserving explicit opt-out and one-time Android permission behavior; settings/policy tests pass. - [x] Verify current trusted EMDK Code128, QR leading zeros and menu/reopen on installed checkpoint1c766bf/E34AC3C6. diff --git a/qqq/android/core/build.gradle.kts b/qqq/android/core/build.gradle.kts index 0920d0f..39264ea 100644 --- a/qqq/android/core/build.gradle.kts +++ b/qqq/android/core/build.gradle.kts @@ -1,3 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) diff --git a/qqq/android/core/src/androidTest/java/com/kingsrook/qqq/frontend/android/core/ExampleInstrumentedTest.kt b/qqq/android/core/src/androidTest/java/com/kingsrook/qqq/frontend/android/core/ExampleInstrumentedTest.kt index 5338cda..a8f27b9 100644 --- a/qqq/android/core/src/androidTest/java/com/kingsrook/qqq/frontend/android/core/ExampleInstrumentedTest.kt +++ b/qqq/android/core/src/androidTest/java/com/kingsrook/qqq/frontend/android/core/ExampleInstrumentedTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -26,21 +27,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* +import org.junit.Assert.assertEquals -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ +/******************************************************************************* + ** Instrumented test, which will execute on an Android device. + ** See [testing documentation](http://d.android.com/tools/testing). + *******************************************************************************/ @RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest -{ +class ExampleInstrumentedTest { + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun useAppContext() - { + fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.kingsrook.qqq.frontend.android.core.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt index 965be5a..9129b3a 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -46,26 +47,21 @@ import java.util.TimeZone import java.util.concurrent.ConcurrentHashMap -/*************************************************************************** - ** - ***************************************************************************/ -class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val compatibility: UiApiCompatibility? = null, private val additionalHeaders: (String) -> Map = { emptyMap() }) : QQQRepository -{ +/******************************************************************************* + *******************************************************************************/ +class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val compatibility: UiApiCompatibility? = null, private val additionalHeaders: (String) -> Map = { emptyMap() }) : QQQRepository { private val sessionUUIDs = ConcurrentHashMap() private val retroJson = Json { ignoreUnknownKeys = true } private val compatibilityByServer = ConcurrentHashMap() private val qqqApiServiceMap: MutableMap = mutableMapOf() - /*************************************************************************** - ** - ***************************************************************************/ - override fun setEnvironment(environment: Environment) - { + /******************************************************************************* + *******************************************************************************/ + override fun setEnvironment(environment: Environment) { baseUrl = environment.baseUrl - if(qqqApiServiceMap[baseUrl] == null) - { + if (qqqApiServiceMap[baseUrl] == null) { val thisRetrofit = buildRetrofit(baseUrl) val thisQQQApiService = thisRetrofit.create(QQQApiService::class.java) qqqApiServiceMap[baseUrl] = thisQQQApiService @@ -75,11 +71,9 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs // retrofit.create(QQQApiService::class.java) } - /*************************************************************************** - ** - ***************************************************************************/ - private fun buildRetrofit(baseUrl: String): Retrofit - { + /******************************************************************************* + *******************************************************************************/ + private fun buildRetrofit(baseUrl: String): Retrofit { return Retrofit.Builder() .addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType())) .client( @@ -97,15 +91,14 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs .addInterceptor { chain -> val request = chain.request().newBuilder() val deferredSession = chain.request().tag(DeferredSessionCookie::class.java) != null - if(!deferredSession) sessionUUIDs[baseUrl]?.takeIf { it.isNotBlank() }?.let { request.header("Cookie", "sessionUUID=$it") } + if (!deferredSession) sessionUUIDs[baseUrl]?.takeIf { it.isNotBlank() }?.let { request.header("Cookie", "sessionUUID=$it") } additionalHeaders(baseUrl).forEach { (name, value) -> request.header(name, value) } request.header("X-QQQ-Client", ANDROID_CLIENT) request.header("X-QQQ-UserTimezone", TimeZone.getDefault().toZoneId().toString()) val response = chain.proceed(request.build()) response.headers("Set-Cookie").forEach { header -> Cookie.parse(response.request.url, header)?.let { cookie -> - if(!deferredSession && cookie.name == "sessionUUID" && cookie.matches(response.request.url)) - { + if (!deferredSession && cookie.name == "sessionUUID" && cookie.matches(response.request.url)) { sessionUUIDs[baseUrl] = cookie.value } } @@ -118,34 +111,33 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs .build() } - /*************************************************************************** - ** - ***************************************************************************/ - override fun setSessionUUID(sessionUUID: String) - { + /******************************************************************************* + *******************************************************************************/ + override fun setSessionUUID(sessionUUID: String) { sessionUUIDs[baseUrl] = sessionUUID } - /*************************************************************************** - ** - ***************************************************************************/ - override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData - { - // return qqqApiService.getAuthenticationMetaData() + /******************************************************************************* + *******************************************************************************/ + override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData { return qqqApiServiceMap[baseUrl]!!.getAuthenticationMetaData() } - /*************************************************************************** - ** - ***************************************************************************/ - override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse - { + /******************************************************************************* + *******************************************************************************/ + override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse { val service = requireNotNull(qqqApiServiceMap[baseUrl]) return resolveSession(service.manageSession(body, DeferredSessionCookie())) } + /******************************************************************************* + ** Discover native session and nonce-validation support before starting browser authorization. + *******************************************************************************/ override suspend fun verifyNativeSessionLogin() = verifyNativeSessionLoginAt(baseUrl) + /******************************************************************************* + ** Check the captured origin's capability without allowing a later server switch to redirect the exchange. + *******************************************************************************/ private suspend fun verifyNativeSessionLoginAt(server: String) = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { val version = compatibilityByServer.getOrPut(server) { @@ -154,10 +146,14 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs UiApiCompatibility.validateNativeSessionLogin(version) } - override suspend fun exchangeNativeSession(body: ManageSessionRequest): ManageSessionResponse - { - require(body.authenticationFlow == com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData.SESSION_FLOW && - com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.isValidExpectedNonce(body.expectedNonce)) { + /******************************************************************************* + ** Return a provisional session from the initiating origin; only the current login attempt may adopt it. + *******************************************************************************/ + override suspend fun exchangeNativeSession(body: ManageSessionRequest): ManageSessionResponse { + require( + body.authenticationFlow == com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData.SESSION_FLOW && + com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.isValidExpectedNonce(body.expectedNonce) + ) { "Native sign-in requires its original nonce" } val server = baseUrl @@ -167,164 +163,168 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs return resolveSession(response) } - /** Login cookies remain provisional until the initiating ViewModel accepts the response. */ - private fun resolveSession(response: retrofit2.Response): ManageSessionResponse - { - if(!response.isSuccessful) throw retrofit2.HttpException(response) + /******************************************************************************* + ** Login cookies remain provisional until the initiating ViewModel accepts the response. + *******************************************************************************/ + private fun resolveSession(response: retrofit2.Response): ManageSessionResponse { + if (!response.isSuccessful) throw retrofit2.HttpException(response) val result = response.body() ?: throw IllegalStateException("Sign-in response did not establish a session") val requestUrl = response.raw().request.url val cookie = response.headers().values("Set-Cookie").mapNotNull { Cookie.parse(requestUrl, it) } .lastOrNull { it.name == "sessionUUID" && it.matches(requestUrl) }?.value?.takeIf { it.isNotBlank() } return result.copy(uuid = cookie ?: result.uuid?.takeIf { it.isNotBlank() } - ?: throw IllegalStateException("Sign-in response did not establish a session")) + ?: throw IllegalStateException("Sign-in response did not establish a session")) } - override suspend fun discardNativeSession(server: String, session: String) - { + /******************************************************************************* + ** Revoke an abandoned exchange at its original server without activating its response cookie. + *******************************************************************************/ + override suspend fun discardNativeSession(server: String, session: String) { val service = qqqApiServiceMap[server] ?: return service.discardNativeSession("sessionUUID=$session", DeferredSessionCookie()).close() } - /** Send credentials only to the session endpoint, never to subsequent requests. */ - override suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse - { + /******************************************************************************* + ** Send credentials only to the session endpoint, never to subsequent requests. + *******************************************************************************/ + override suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse { val service = requireNotNull(qqqApiServiceMap[baseUrl]) return resolveSession(service.loginWithPassword(Credentials.basic(username, password, Charsets.UTF_8), emptyMap(), DeferredSessionCookie())) } - /** Preserve the cookie until the server has received the revocation request. */ - override suspend fun logout() - { + /******************************************************************************* + ** Preserve the cookie until the server has received the revocation request. + *******************************************************************************/ + override suspend fun logout() { val server = baseUrl val session = sessionUUIDs[server] - try { if(!session.isNullOrBlank()) discardNativeSession(server, session) } - finally { if(session != null) sessionUUIDs.remove(server, session) } + try { + if (!session.isNullOrBlank()) discardNativeSession(server, session) + } finally { + if (session != null) sessionUUIDs.remove(server, session) + } } - /** Capture the original origin/cookie; neither a response cookie nor a server switch can adopt a session. */ - override suspend fun logoutWithProvider(postLogoutRedirectUri: String): NativeLogoutData? - { + /******************************************************************************* + ** Capture the original origin/cookie; neither a response cookie nor a server switch can adopt a session. + *******************************************************************************/ + override suspend fun logoutWithProvider(postLogoutRedirectUri: String): NativeLogoutData? { val server = baseUrl val session = sessionUUIDs[server] ?: return null - try - { + try { val supported = supportsNativeSessionLogoutAt(server) - if(!supported) - { + if (!supported) { discardNativeSession(server, session) return null } - return requireNotNull(qqqApiServiceMap[server]).logoutWithProvider("sessionUUID=$session", - NativeLogoutRequest(NativeLogout(postLogoutRedirectUri)), DeferredSessionCookie()).use { response -> + return requireNotNull(qqqApiServiceMap[server]).logoutWithProvider( + "sessionUUID=$session", + NativeLogoutRequest(NativeLogout(postLogoutRedirectUri)), DeferredSessionCookie() + ).use { response -> // Parse privately: serialization errors may include input fragments, so never propagate them. val source = response.source() source.request(65537) - if(source.buffer.size > 65536) return@use null - try { retroJson.decodeFromString(source.readUtf8()).nativeLogoutData } - catch(exception: Exception) { null } + if (source.buffer.size > 65536) return@use null + try { + retroJson.decodeFromString(source.readUtf8()).nativeLogoutData + } catch (exception: Exception) { + null + } } + } catch (exception: UiApiException) { + throw java.io.IOException("Company sign-out request failed") + } finally { + sessionUUIDs.remove(server, session) } - catch(exception: UiApiException) { throw java.io.IOException("Company sign-out request failed") } - finally { sessionUUIDs.remove(server, session) } } + /******************************************************************************* + ** Discover optional company sign-out independently from native login support. + *******************************************************************************/ override suspend fun supportsNativeSessionLogout(): Boolean = supportsNativeSessionLogoutAt(baseUrl) + /******************************************************************************* + ** Use the captured server's public capability before sending its existing session for company sign-out. + *******************************************************************************/ private suspend fun supportsNativeSessionLogoutAt(server: String): Boolean = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - try - { + try { val version = compatibilityByServer.getOrPut(server) { compatibility?.takeIf { it.server == server } ?: UiApiCompatibility(server, observer = { requestObserver(it) }) }.verify() UiApiCompatibility.validateNativeSessionLogout(version) true + } catch (exception: UiApiCompatibilityException) { + false } - catch(exception: UiApiCompatibilityException) { false } } - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ override suspend fun getMetaData( frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String - ): QInstance - { + ): QInstance { return qqqApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion) } - /*************************************************************************** - ** - ***************************************************************************/ - override suspend fun getProcessMetaData(processName: String): QProcessMetaData - { + /******************************************************************************* + *******************************************************************************/ + override suspend fun getProcessMetaData(processName: String): QProcessMetaData { return qqqApiServiceMap[baseUrl]!!.getProcessMetaData(processName) } - /*************************************************************************** - ** - ***************************************************************************/ - override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult - { + /******************************************************************************* + *******************************************************************************/ + override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult { return qqqApiServiceMap[baseUrl]!!.processInit(processName, buildProcessInitOrStepRequestBodyV1(values, recordsParam, recordIds, filterJSON, stepTimeoutMillis)) } - /*************************************************************************** - ** - ***************************************************************************/ - override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult - { + /******************************************************************************* + *******************************************************************************/ + override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult { return qqqApiServiceMap[baseUrl]!!.processStep(processName, processUUID, stepName, buildProcessInitOrStepRequestBodyV1(values, stepTimeoutMillis = stepTimeoutMillis)) } - /*************************************************************************** - ** - ***************************************************************************/ - override suspend fun processCancel(processName: String, processUUID: String) - { + /******************************************************************************* + *******************************************************************************/ + override suspend fun processCancel(processName: String, processUUID: String) { qqqApiServiceMap[baseUrl]!!.processCancel(processName, processUUID).close() } - override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult - { + /******************************************************************************* + ** Poll the exact process/job identity supplied by the server instead of assuming background completion. + *******************************************************************************/ + override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult { return qqqApiServiceMap[baseUrl]!!.processJobStatus(processName, processUUID, jobUUID) } - /*************************************************************************** - ** - ***************************************************************************/ - private fun buildProcessInitOrStepRequestBodyV1(values: Map, recordsParam: String? = null, recordIds: String? = null, filterJSON: String? = null, stepTimeoutMillis: Int? = null): RequestBody - { + /******************************************************************************* + *******************************************************************************/ + private fun buildProcessInitOrStepRequestBodyV1(values: Map, recordsParam: String? = null, recordIds: String? = null, filterJSON: String? = null, stepTimeoutMillis: Int? = null): RequestBody { val bodyBuilder = MultipartBody.Builder() .setType(MultipartBody.FORM) - if(!values.isEmpty()) - { + if (!values.isEmpty()) { val valuesJson = JSONObject() - for(entry in values.entries) - { + for (entry in values.entries) { valuesJson.put(entry.key, entry.value ?: JSONObject.NULL) } bodyBuilder.addFormDataPart("values", valuesJson.toString()) } - if(recordsParam != null) - { + if (recordsParam != null) { bodyBuilder.addFormDataPart("recordsParam", recordsParam) } - if(recordIds != null) - { + if (recordIds != null) { bodyBuilder.addFormDataPart("recordIds", recordIds) } - if(filterJSON != null) - { + if (filterJSON != null) { bodyBuilder.addFormDataPart("filterJSON", filterJSON) } - if(stepTimeoutMillis != null) - { + if (stepTimeoutMillis != null) { bodyBuilder.addFormDataPart("stepTimeoutMillis", stepTimeoutMillis.toString()) } @@ -332,22 +332,18 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs return requestBody } - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ - /*************************************************************************** - ** - ***************************************************************************/ - override fun getURI(path: String): String - { + /******************************************************************************* + *******************************************************************************/ + override fun getURI(path: String): String { val uri = java.net.URI(baseUrl).resolve(path) require(uri.scheme in setOf("https", "http") && uri.userInfo == null && uri.host != null) { "Unsupported media URL" } return uri.toASCIIString() } - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt index 2a3e118..3aa3d3f 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -38,49 +39,84 @@ import retrofit2.http.Query const val BASE_PATH = "/qqq/v1/" -/** A login response is adopted only after its initiating ViewModel accepts it. */ +/******************************************************************************* + ** A login response is adopted only after its initiating ViewModel accepts it. + *******************************************************************************/ class DeferredSessionCookie -/*************************************************************************** +/******************************************************************************* ** Class (interface?) that instructs retrofit how to make HTTP requests ** to a QQQ middleware server. - ***************************************************************************/ -interface QQQApiService -{ + *******************************************************************************/ +interface QQQApiService { ////////////////////////////// // authentication endpoints // ////////////////////////////// + /******************************************************************************* + ** Discover the versioned server authentication contract before submitting credentials. + *******************************************************************************/ @GET(BASE_PATH + "metaData/authentication") suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData + /******************************************************************************* + ** Exchange authentication data for a provisional QQQ session without adopting a cookie implicitly. + *******************************************************************************/ @POST(BASE_PATH + "manageSession") - suspend fun manageSession(@Body body: ManageSessionRequest, - @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie): retrofit2.Response + suspend fun manageSession( + @Body body: ManageSessionRequest, + @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie + ): retrofit2.Response + /******************************************************************************* + ** Send the native authorization exchange only to the versioned session endpoint. + *******************************************************************************/ @POST(BASE_PATH + "manageSession") - suspend fun exchangeNativeSession(@Body body: ManageSessionRequest, - @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie): retrofit2.Response + suspend fun exchangeNativeSession( + @Body body: ManageSessionRequest, + @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie + ): retrofit2.Response + /******************************************************************************* + ** Keep password credentials confined to the session endpoint rather than ordinary process requests. + *******************************************************************************/ @POST(BASE_PATH + "manageSession") - suspend fun loginWithPassword(@Header("Authorization") authorization: String, @Body body: Map, - @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie): retrofit2.Response + suspend fun loginWithPassword( + @Header("Authorization") authorization: String, @Body body: Map, + @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie + ): retrofit2.Response + /******************************************************************************* + ** Revoke the supplied QQQ session before its caller discards local credentials. + *******************************************************************************/ @POST(BASE_PATH + "logout") suspend fun logout(): ResponseBody + /******************************************************************************* + ** Request optional company sign-out using the existing session and a registered callback. + *******************************************************************************/ @POST(BASE_PATH + "logout") @retrofit2.http.Streaming - suspend fun logoutWithProvider(@Header("Cookie") cookie: String, + suspend fun logoutWithProvider( + @Header("Cookie") cookie: String, @Body body: com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.NativeLogoutRequest, - @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie): ResponseBody + @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie + ): ResponseBody + /******************************************************************************* + ** Revoke a captured abandoned session without installing its cookie in the active repository. + *******************************************************************************/ @POST(BASE_PATH + "logout") - suspend fun discardNativeSession(@Header("Cookie") cookie: String, - @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie): ResponseBody + suspend fun discardNativeSession( + @Header("Cookie") cookie: String, + @retrofit2.http.Tag cookiePolicy: DeferredSessionCookie + ): ResponseBody /////////////////// // qqq meta data // /////////////////// + /******************************************************************************* + ** Load semantic metadata with explicit frontend and application versions for compatibility decisions. + *******************************************************************************/ @GET(BASE_PATH + "metaData") suspend fun getMetaData( @Query("frontendName") frontendName: String, @@ -92,15 +128,24 @@ interface QQQApiService /////////////// // processes // /////////////// + /******************************************************************************* + ** Read the server-defined process contract rather than deriving behavior from its name. + *******************************************************************************/ @GET(BASE_PATH + "metaData/process/{processName}") suspend fun getProcessMetaData(@Path(value = "processName") processName: String): QProcessMetaData + /******************************************************************************* + ** Start process execution with explicit record selection and values, returning authoritative server step state. + *******************************************************************************/ @POST(BASE_PATH + "processes/{processName}/init") suspend fun processInit( @Path(value = "processName") processName: String, @Body body: RequestBody ): ProcessStepResult + /******************************************************************************* + ** Submit values to the identified server process instance and step. + *******************************************************************************/ @POST(BASE_PATH + "processes/{processName}/{processUUID}/step/{stepName}") suspend fun processStep( @Path(value = "processName") processName: String, @@ -109,6 +154,9 @@ interface QQQApiService @Body body: RequestBody ): ProcessStepResult + /******************************************************************************* + ** Poll the identified server job within its owning process instance. + *******************************************************************************/ @GET(BASE_PATH + "processes/{processName}/{processUUID}/status/{jobUUID}") suspend fun processJobStatus( @Path(value = "processName") processName: String, @@ -116,6 +164,9 @@ interface QQQApiService @Path(value = "jobUUID") jobUUID: String, ): ProcessStepResult + /******************************************************************************* + ** Cancel the identified server process explicitly instead of dropping only client navigation. + *******************************************************************************/ @POST(BASE_PATH + "processes/{processName}/{processUUID}/cancel") suspend fun processCancel(@Path("processName") processName: String, @Path("processUUID") processUUID: String): ResponseBody diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt index cd14722..bf7506f 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -29,60 +30,108 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.Man import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult -/*************************************************************************** +/******************************************************************************* ** interface that defines the interaction points we have with a QQQ backend. - ***************************************************************************/ -interface QQQRepository -{ + *******************************************************************************/ +interface QQQRepository { + /******************************************************************************* + ** Select the server environment before requests; implementations must isolate session state by server. + *******************************************************************************/ fun setEnvironment(environment: Environment) + /******************************************************************************* + ** Activate only a session accepted by the current login attempt, never an abandoned provisional response. + *******************************************************************************/ fun setSessionUUID(sessionUUID: String) + /******************************************************************************* + ** Discover the server's authentication contract without submitting credentials. + *******************************************************************************/ suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData - /** Return a provisional session; the initiating login must accept it before calling setSessionUUID. */ + /******************************************************************************* + ** Return a provisional session; the initiating login must accept it before calling setSessionUUID. + *******************************************************************************/ suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse - /** Native browser sign-in requires explicit version/feature discovery before authorization. */ - suspend fun verifyNativeSessionLogin() { throw UnsupportedOperationException("Native browser sign-in is unavailable") } + /******************************************************************************* + ** Native browser sign-in requires explicit version/feature discovery before authorization. + *******************************************************************************/ + suspend fun verifyNativeSessionLogin() { + throw UnsupportedOperationException("Native browser sign-in is unavailable") + } - /** Return the new session without activating it; the caller must validate its pending login first. */ + /******************************************************************************* + ** Return the new session without activating it; the caller must validate its pending login first. + *******************************************************************************/ suspend fun exchangeNativeSession(body: ManageSessionRequest): ManageSessionResponse = throw UnsupportedOperationException("Native browser sign-in is unavailable") - /** Revoke an abandoned exchange at its original server without activating its cookie. */ - suspend fun discardNativeSession(server: String, session: String) { } + /******************************************************************************* + ** Revoke an abandoned exchange at its original server without activating its cookie. + *******************************************************************************/ + suspend fun discardNativeSession(server: String, session: String) {} - /** Authenticate without retaining the password or adopting the provisional response cookie. */ + /******************************************************************************* + ** Authenticate without retaining the password or adopting the provisional response cookie. + *******************************************************************************/ suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse = throw UnsupportedOperationException("Password authentication is unavailable") - /** End the server session before discarding local credentials. */ - suspend fun logout() { } + /******************************************************************************* + ** End the server session before discarding local credentials. + *******************************************************************************/ + suspend fun logout() {} - /** Public discovery only. Absence retains ordinary local sign-out. */ + /******************************************************************************* + ** Public discovery only. Absence retains ordinary local sign-out. + *******************************************************************************/ suspend fun supportsNativeSessionLogout(): Boolean = false - /** Explicit optional company sign-out. Unsupported repositories still perform ordinary logout. */ - suspend fun logoutWithProvider(postLogoutRedirectUri: String): com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.NativeLogoutData? - { + /******************************************************************************* + ** Explicit optional company sign-out. Unsupported repositories still perform ordinary logout. + *******************************************************************************/ + suspend fun logoutWithProvider(postLogoutRedirectUri: String): com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.NativeLogoutData? { logout() return null } + /******************************************************************************* + ** Load the semantic application model with explicit client identity for server compatibility decisions. + *******************************************************************************/ suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance + /******************************************************************************* + ** Resolve the server-defined process contract instead of deriving actions from its name. + *******************************************************************************/ suspend fun getProcessMetaData(processName: String): QProcessMetaData + /******************************************************************************* + ** Start server-defined process execution with explicit values and record selection; return authoritative step state. + *******************************************************************************/ suspend fun processInit(processName: String, values: Map, recordsParam: String? = null, recordIds: String? = null, filterJSON: String? = null, stepTimeoutMillis: Int? = 3000): ProcessStepResult + /******************************************************************************* + ** Submit to the existing process instance and server-named step, preserving the caller's timeout policy. + *******************************************************************************/ suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult - suspend fun processCancel(processName: String, processUUID: String) { throw UnsupportedOperationException("Process cancellation is unavailable") } + /******************************************************************************* + ** Cancel the existing server process explicitly; unsupported implementations must fail visibly. + *******************************************************************************/ + suspend fun processCancel(processName: String, processUUID: String) { + throw UnsupportedOperationException("Process cancellation is unavailable") + } + /******************************************************************************* + ** Poll the server-issued job within its process instance instead of inferring asynchronous completion. + *******************************************************************************/ suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult + /******************************************************************************* + ** Resolve a repository resource path against the currently selected server environment. + *******************************************************************************/ fun getURI(path: String): String } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/RequestOutcomeInterceptor.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/RequestOutcomeInterceptor.kt index 8b27c37..abda969 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/RequestOutcomeInterceptor.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/RequestOutcomeInterceptor.kt @@ -5,25 +5,30 @@ import java.io.IOException import okhttp3.Interceptor import okhttp3.Response -/** Deliberately excludes URLs, headers, bodies, and exception messages. Null status means transport failure. */ +/******************************************************************************* + ** Deliberately excludes URLs, headers, bodies, and exception messages. Null status means transport failure. + *******************************************************************************/ data class RequestOutcome(val httpStatus: Int?, val elapsedMillis: Long) -/** Optional diagnostics must never change request behavior, even if an observer throws. */ -class RequestOutcomeInterceptor(private val observer: (RequestOutcome) -> Unit) : Interceptor -{ - override fun intercept(chain: Interceptor.Chain): Response - { +/******************************************************************************* + ** Optional diagnostics must never change request behavior, even if an observer throws. + *******************************************************************************/ +class RequestOutcomeInterceptor(private val observer: (RequestOutcome) -> Unit) : Interceptor { + /******************************************************************************* + ** Observe status and monotonic duration without retaining sensitive request data or changing HTTP behavior. + *******************************************************************************/ + override fun intercept(chain: Interceptor.Chain): Response { val started = System.nanoTime() - fun report(status: Int?) - { + + /******************************************************************************* + ** Isolate observer failures so optional diagnostics cannot fail the original request. + *******************************************************************************/ + fun report(status: Int?) { runCatching { observer(RequestOutcome(status, (System.nanoTime() - started) / 1_000_000)) } } - try - { + try { return chain.proceed(chain.request()).also { report(it.code) } - } - catch(exception: IOException) - { + } catch (exception: IOException) { report(null) throw exception } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt index b008b96..5e97a89 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt @@ -11,82 +11,111 @@ import okhttp3.Request const val ANDROID_CLIENT = "android/qqq-mobile" const val MOBILE_SCANNERS_PATH = "/qqq/v1/qbit/mobile-scanners" +/******************************************************************************* + ** Carry the named extension's version and registered path prefix for pre-credential compatibility checks. + *******************************************************************************/ @Serializable data class UiApiExtension(val name: String, val version: String, val pathPrefix: String) +/******************************************************************************* + ** Represent public server capabilities independently of any authenticated session. + *******************************************************************************/ @Serializable -data class UiApiVersion(val apiVersion: String, val features: List = emptyList(), val extensions: List = emptyList(), - val clientChannels: List = emptyList()) +data class UiApiVersion( + val apiVersion: String, val features: List = emptyList(), val extensions: List = emptyList(), + val clientChannels: List = emptyList() +) +/******************************************************************************* + ** Stop unsupported client/server combinations with a safe operator-facing explanation. + *******************************************************************************/ class UiApiCompatibilityException(message: String) : IOException(message) -/** One origin and one host lifetime. Discovery uses a separate client with no credentials or cookies. */ -class UiApiCompatibility(val server: String, private val requireMobileScanners: Boolean = false, - private val observer: (RequestOutcome) -> Unit = {}) -{ +/******************************************************************************* + ** One origin and one host lifetime. Discovery uses a separate client with no credentials or cookies. + *******************************************************************************/ +class UiApiCompatibility( + val server: String, private val requireMobileScanners: Boolean = false, + private val observer: (RequestOutcome) -> Unit = {} +) { private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) .callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(observer)).build() - @Volatile private var verified: UiApiVersion? = null - /** Call on an IO thread. Synchronization prevents competing startup requests bypassing discovery. */ + @Volatile + private var verified: UiApiVersion? = null + + /******************************************************************************* + ** Call on an IO thread. Synchronization prevents competing startup requests bypassing discovery. + *******************************************************************************/ @Synchronized - fun verify(): UiApiVersion - { + fun verify(): UiApiVersion { verified?.let { return it } val request = Request.Builder().url(server.trimEnd('/') + "/qqq/v1/version") .header("X-QQQ-Client", ANDROID_CLIENT).header("Cache-Control", "no-store").build() val version = client.newCall(request).execute().use { response -> - if(!response.isSuccessful) throw UiApiCompatibilityException("This server does not provide a supported QQQ UI API. Ask your administrator to upgrade the server.") + if (!response.isSuccessful) throw UiApiCompatibilityException("This server does not provide a supported QQQ UI API. Ask your administrator to upgrade the server.") val source = response.body?.source() source?.request(65537) val bytes = source?.buffer?.readByteArray(minOf(source.buffer.size, 65537)) ?: byteArrayOf() - if(bytes.size > 65536) throw UiApiCompatibilityException("The server returned invalid QQQ UI API version information.") - try { Json { ignoreUnknownKeys = true; coerceInputValues = true }.decodeFromString(bytes.toString(Charsets.UTF_8)) } - catch(exception: Exception) { throw UiApiCompatibilityException("The server returned invalid QQQ UI API version information.") } + if (bytes.size > 65536) throw UiApiCompatibilityException("The server returned invalid QQQ UI API version information.") + try { + Json { ignoreUnknownKeys = true; coerceInputValues = true }.decodeFromString(bytes.toString(Charsets.UTF_8)) + } catch (exception: Exception) { + throw UiApiCompatibilityException("The server returned invalid QQQ UI API version information.") + } } validate(version, requireMobileScanners) verified = version return version } - companion object - { + /******************************************************************************* + ** Keep supported protocol ranges and feature checks shared by startup and optional authentication discovery. + *******************************************************************************/ + companion object { // Compiled and tested against the published 1.8.0 contract; additive 1.x versions remain compatible. const val SUPPORTED_CORE_RANGE = ">=1.8.0 <2.0.0" const val SUPPORTED_MOBILE_RANGE = ">=1.1.0 <2.0.0" val REQUIRED_FEATURES = setOf("version", "metaData", "processes", "processCancel", "extensions", "widgets") - fun validateNativeSessionLogin(version: UiApiVersion) - { + /******************************************************************************* + ** Require the original-nonce native session contract before opening the authorization browser. + *******************************************************************************/ + fun validateNativeSessionLogin(version: UiApiVersion) { val parts = Regex("1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)").matchEntire(version.apiVersion)?.groupValues - if(parts == null || (parts[1].toIntOrNull() ?: 0) < 10 || - !version.features.containsAll(listOf("nativeSessionLogin", "nativeSessionNonceValidation"))) + if (parts == null || (parts[1].toIntOrNull() ?: 0) < 10 || + !version.features.containsAll(listOf("nativeSessionLogin", "nativeSessionNonceValidation")) + ) throw UiApiCompatibilityException("This server does not advertise native browser sign-in with required nonce validation. Ask your administrator to enable the supported login flow.") } - /** The logout capability is additive and must never be inferred from native login support. */ - fun validateNativeSessionLogout(version: UiApiVersion) - { + /******************************************************************************* + ** The logout capability is additive and must never be inferred from native login support. + *******************************************************************************/ + fun validateNativeSessionLogout(version: UiApiVersion) { val parts = Regex("1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)").matchEntire(version.apiVersion)?.groupValues - if(parts == null || (parts[1].toIntOrNull() ?: 0) < 10 || "nativeSessionLogout" !in version.features) + if (parts == null || (parts[1].toIntOrNull() ?: 0) < 10 || "nativeSessionLogout" !in version.features) throw UiApiCompatibilityException("This server does not advertise company sign-out.") } - fun validate(version: UiApiVersion, requireMobileScanners: Boolean) - { - fun supported(value: String, minMinor: Int): Boolean - { + /******************************************************************************* + ** Require compatible API semantics, Android channel and optional scanner extension before credentials are sent. + *******************************************************************************/ + fun validate(version: UiApiVersion, requireMobileScanners: Boolean) { + /******************************************************************************* + ** Accept additive versions in the supported major while rejecting malformed or overflowing version components. + *******************************************************************************/ + fun supported(value: String, minMinor: Int): Boolean { val parts = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)").matchEntire(value)?.groupValues ?: return false return parts[1] == "1" && (parts[2].toIntOrNull() ?: return false) >= minMinor && parts[3].toIntOrNull() != null } - if(!supported(version.apiVersion, 8)) throw UiApiCompatibilityException("Unsupported QQQ UI API version. This app requires $SUPPORTED_CORE_RANGE. Update the app or ask your administrator to upgrade the server.") - if(!version.features.containsAll(REQUIRED_FEATURES)) throw UiApiCompatibilityException("This server is missing required QQQ UI API features. Ask your administrator to upgrade the server.") - if("android" !in version.clientChannels) throw UiApiCompatibilityException("This server does not support the Android client channel. Ask your administrator to enable the android channel.") - if(requireMobileScanners) - { + if (!supported(version.apiVersion, 8)) throw UiApiCompatibilityException("Unsupported QQQ UI API version. This app requires $SUPPORTED_CORE_RANGE. Update the app or ask your administrator to upgrade the server.") + if (!version.features.containsAll(REQUIRED_FEATURES)) throw UiApiCompatibilityException("This server is missing required QQQ UI API features. Ask your administrator to upgrade the server.") + if ("android" !in version.clientChannels) throw UiApiCompatibilityException("This server does not support the Android client channel. Ask your administrator to enable the android channel.") + if (requireMobileScanners) { val extension = version.extensions.singleOrNull { it.name == "mobile-scanners" } ?: throw UiApiCompatibilityException("This server is missing the mobile-scanners extension. Ask your administrator to install it.") - if(!supported(extension.version, 1) || extension.pathPrefix != "/qbit/mobile-scanners") + if (!supported(extension.version, 1) || extension.pathPrefix != "/qbit/mobile-scanners") throw UiApiCompatibilityException("Unsupported mobile-scanners extension. This app requires $SUPPORTED_MOBILE_RANGE at /qbit/mobile-scanners. Update the app or server extension.") } } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiException.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiException.kt index 8c6969b..7d7d9a8 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiException.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiException.kt @@ -7,39 +7,61 @@ import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.Response +/******************************************************************************* + ** Associate a server validation message with its semantic field without adding it to diagnostic telemetry. + *******************************************************************************/ @Serializable data class UiApiFieldError(val field: String = "", val message: String = "", val code: String? = null) +/******************************************************************************* + ** Decode the bounded semantic error envelope while tolerating additive server fields. + *******************************************************************************/ @Serializable -private data class UiApiErrorBody(val message: String? = null, val code: String? = null, - val fieldErrors: List = emptyList(), val traceId: String? = null) +private data class UiApiErrorBody( + val message: String? = null, val code: String? = null, + val fieldErrors: List = emptyList(), val traceId: String? = null +) -/** Human-facing server errors are available to the UI, never to the diagnostic event log. */ -class UiApiException(val httpStatus: Int, val code: String, message: String, - val fieldErrors: List = emptyList(), val traceId: String? = null) : IOException(message) -{ - companion object - { - fun from(response: Response): UiApiException - { +/******************************************************************************* + ** Human-facing server errors are available to the UI, never to the diagnostic event log. + *******************************************************************************/ +class UiApiException( + val httpStatus: Int, val code: String, message: String, + val fieldErrors: List = emptyList(), val traceId: String? = null +) : IOException(message) { + /******************************************************************************* + ** Centralize bounded response parsing and safe fallback messages for HTTP errors. + *******************************************************************************/ + companion object { + /******************************************************************************* + ** Peek at a bounded error body without consuming response ownership and retain a safe HTTP fallback. + *******************************************************************************/ + fun from(response: Response): UiApiException { val body = runCatching { Json { ignoreUnknownKeys = true }.decodeFromString(response.peekBody(65536).string()) }.getOrNull() - val fallback = when(response.code) { + val fallback = when (response.code) { 400 -> "BAD_REQUEST"; 401 -> "UNAUTHENTICATED"; 403 -> "PERMISSION_DENIED" 404 -> "NOT_FOUND"; 409 -> "CONFLICT"; 429 -> "RATE_LIMITED"; 500 -> "INTERNAL_ERROR"; else -> "ERROR" } - return UiApiException(response.code, body?.code ?: fallback, + return UiApiException( + response.code, body?.code ?: fallback, body?.message?.takeIf { it.isNotBlank() } ?: "The server could not complete this request (HTTP ${response.code}).", body?.fieldErrors ?: emptyList(), body?.traceId ?: response.header("X-QQQ-Trace-Id")) } } } -class UiApiErrorInterceptor : Interceptor -{ - override fun intercept(chain: Interceptor.Chain): Response - { +/******************************************************************************* + ** Convert failed responses into semantic exceptions while closing their network resources. + *******************************************************************************/ +class UiApiErrorInterceptor : Interceptor { + /******************************************************************************* + ** Return successful response ownership to the caller and close failed responses before throwing. + *******************************************************************************/ + override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) - if(!response.isSuccessful) { val error = UiApiException.from(response); response.close(); throw error } + if (!response.isSuccessful) { + val error = UiApiException.from(response); response.close(); throw error + } return response } } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/Environment.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/Environment.kt index 9086471..ef220ea 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/Environment.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/Environment.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,13 +23,11 @@ package com.kingsrook.qqq.frontend.android.core.model import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class Environment( val label: String, val baseUrl: String, -) -{ -} \ No newline at end of file +) { +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/GenericSerializing.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/GenericSerializing.kt index 06a2d6d..f398f32 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/GenericSerializing.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/GenericSerializing.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -49,59 +50,82 @@ typealias MapOfStringAny = Map typealias ListOfAny = List<@Serializable(with = AnyValueSerializer::class) Any?> -/** Legacy public model retained for source compatibility; it has no special meaning in API JSON. */ +/******************************************************************************* + ** Legacy public model retained for source compatibility; it has no special meaning in API JSON. + *******************************************************************************/ @Serializable data class AnyValueSurrogate(val type: String, @Contextual val value: Any?) -/** Legacy null marker retained for callers; API nulls use JSON null. */ +/******************************************************************************* + ** Legacy null marker retained for callers; API nulls use JSON null. + *******************************************************************************/ @Serializable object NoneType -/** Wrapper retained for clients using the original generic serializer API. */ +/******************************************************************************* + ** Wrapper retained for clients using the original generic serializer API. + *******************************************************************************/ @Serializable(with = AnySerializableValueSerializer::class) data class AnySerializableValue(val value: Any?) -/** Delegate wrapped values to the JSON data serializer without class discovery. */ -object AnySerializableValueSerializer : KSerializer -{ +/******************************************************************************* + ** Delegate wrapped values to the JSON data serializer without class discovery. + *******************************************************************************/ +object AnySerializableValueSerializer : KSerializer { override val descriptor = JsonElement.serializer().descriptor + + /******************************************************************************* + ** Delegate framing to the declared JSON serializer without introducing wire-controlled JVM class discovery. + *******************************************************************************/ override fun serialize(encoder: Encoder, value: AnySerializableValue) = AnyValueSerializer.serialize(encoder, value.value) + + /******************************************************************************* + ** Reconstruct the declared wrapper through its JSON delegate instead of resolving classes from input. + *******************************************************************************/ override fun deserialize(decoder: Decoder) = AnySerializableValue(AnyValueSerializer.deserialize(decoder)) } typealias SerializableMapType = MutableMap typealias SerializableListType = MutableList -/** Legacy collection declarations retain their original public API shape. */ +/******************************************************************************* + ** Legacy collection declarations retain their original public API shape. + *******************************************************************************/ @Serializable abstract class SerializableMap : SerializableMapType -/** Legacy collection declarations retain their original public API shape. */ +/******************************************************************************* + ** Legacy collection declarations retain their original public API shape. + *******************************************************************************/ @Serializable abstract class SerializableList : SerializableListType -/** Semantic API values are JSON data; business keys never select JVM classes. */ -object AnyValueSerializer : KSerializer -{ +/******************************************************************************* + ** Semantic API values are JSON data; business keys never select JVM classes. + *******************************************************************************/ +object AnyValueSerializer : KSerializer { override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor - /** Encode arbitrary API maps/lists without adding class-name envelopes. */ - override fun serialize(encoder: Encoder, value: Any?) - { + /******************************************************************************* + ** Encode arbitrary API maps/lists without adding class-name envelopes. + *******************************************************************************/ + override fun serialize(encoder: Encoder, value: Any?) { val json = encoder as? JsonEncoder ?: throw SerializationException("API values require JSON") json.encodeJsonElement(toJson(value)) } - /** Decode objects uniformly, including objects whose keys happen to be type and value. */ - override fun deserialize(decoder: Decoder): Any? - { + /******************************************************************************* + ** Decode objects uniformly, including objects whose keys happen to be type and value. + *******************************************************************************/ + override fun deserialize(decoder: Decoder): Any? { val json = decoder as? JsonDecoder ?: throw SerializationException("API values require JSON") return fromJson(json.decodeJsonElement()) } - /** Only serialization uses a locally supplied object's serializer; wire input never loads classes. */ - private fun toJson(value: Any?): JsonElement = when(value) - { + /******************************************************************************* + ** Only serialization uses a locally supplied object's serializer; wire input never loads classes. + *******************************************************************************/ + private fun toJson(value: Any?): JsonElement = when (value) { null -> JsonNull is JsonElement -> value is String -> JsonPrimitive(value) @@ -111,44 +135,68 @@ object AnyValueSerializer : KSerializer val name = entry.key as? String ?: throw SerializationException("JSON object keys must be strings") name to toJson(entry.value) }) + is Iterable<*> -> JsonArray(value.map(::toJson)) else -> Json.encodeToJsonElement(serializer(value::class.starProjectedType), value) } - /** Preserve integer values and nulls while keeping arbitrary nested objects as ordinary maps. */ - private fun fromJson(value: JsonElement): Any? = when(value) - { + /******************************************************************************* + ** Preserve integer values and nulls while keeping arbitrary nested objects as ordinary maps. + *******************************************************************************/ + private fun fromJson(value: JsonElement): Any? = when (value) { JsonNull -> null is JsonObject -> value.mapValues { fromJson(it.value) } is JsonArray -> value.map(::fromJson) - is JsonPrimitive -> if(value.isString) value.content else + is JsonPrimitive -> if (value.isString) value.content else value.booleanOrNull ?: value.longOrNull ?: value.doubleOrNull ?: throw SerializationException("Invalid JSON primitive") } } -/** Wrapper retained for callers that serialize arbitrary JSON object values. */ +/******************************************************************************* + ** Wrapper retained for callers that serialize arbitrary JSON object values. + *******************************************************************************/ @Serializable(with = MapStringAnySerializer::class) data class MapStringAny(val entries: Map = emptyMap()) -/** Delegate object framing to the standard map serializer. */ -object MapStringAnySerializer : KSerializer -{ +/******************************************************************************* + ** Delegate object framing to the standard map serializer. + *******************************************************************************/ +object MapStringAnySerializer : KSerializer { private val delegate = MapSerializer(String.serializer(), AnyValueSerializer) override val descriptor = delegate.descriptor + + /******************************************************************************* + ** Delegate framing to the declared JSON serializer without introducing wire-controlled JVM class discovery. + *******************************************************************************/ override fun serialize(encoder: Encoder, value: MapStringAny) = delegate.serialize(encoder, value.entries) + + /******************************************************************************* + ** Reconstruct the declared wrapper through its JSON delegate instead of resolving classes from input. + *******************************************************************************/ override fun deserialize(decoder: Decoder) = MapStringAny(delegate.deserialize(decoder)) } -/** Wrapper retained for callers that serialize arbitrary JSON lists. */ +/******************************************************************************* + ** Wrapper retained for callers that serialize arbitrary JSON lists. + *******************************************************************************/ @Serializable(with = ListAnySerializer::class) data class ListAny(val entries: List = emptyList()) -/** Delegate list framing to the standard list serializer. */ -object ListAnySerializer : KSerializer -{ +/******************************************************************************* + ** Delegate list framing to the standard list serializer. + *******************************************************************************/ +object ListAnySerializer : KSerializer { private val delegate = ListSerializer(AnyValueSerializer) override val descriptor = delegate.descriptor + + /******************************************************************************* + ** Delegate framing to the declared JSON serializer without introducing wire-controlled JVM class discovery. + *******************************************************************************/ override fun serialize(encoder: Encoder, value: ListAny) = delegate.serialize(encoder, value.entries) + + /******************************************************************************* + ** Reconstruct the declared wrapper through its JSON delegate instead of resolving classes from input. + *******************************************************************************/ override fun deserialize(decoder: Decoder) = ListAny(delegate.deserialize(decoder)) } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/QPossibleValue.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/QPossibleValue.kt index f6f28df..d8cee57 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/QPossibleValue.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/QPossibleValue.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,14 +23,12 @@ package com.kingsrook.qqq.frontend.android.core.model import java.io.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @kotlinx.serialization.Serializable data class QPossibleValue( val id: Serializable, val label: String -) -{ +) { } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/AdornmentType.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/AdornmentType.kt index bb27fb9..dfc805d 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/AdornmentType.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/AdornmentType.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -20,12 +21,10 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @kotlinx.serialization.Serializable(with = AdornmentTypeSerializer::class) -enum class AdornmentType -{ +enum class AdornmentType { UNKNOWN, LINK, CHIP, @@ -36,10 +35,20 @@ enum class AdornmentType CODE_EDITOR, } -object AdornmentTypeSerializer : kotlinx.serialization.KSerializer -{ +/******************************************************************************* + ** Keep unknown server enum values representable so additive metadata fails safely at the UI boundary. + *******************************************************************************/ +object AdornmentTypeSerializer : kotlinx.serialization.KSerializer { override val descriptor = kotlinx.serialization.descriptors.PrimitiveSerialDescriptor("AdornmentType", kotlinx.serialization.descriptors.PrimitiveKind.STRING) + + /******************************************************************************* + ** Map known wire names explicitly and preserve unsupported values through the enum's UNKNOWN member. + *******************************************************************************/ override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): AdornmentType = decoder.decodeString().let { value -> AdornmentType.entries.firstOrNull { it.name == value } ?: AdornmentType.UNKNOWN } + + /******************************************************************************* + ** Write the stable enum wire name rather than a JVM implementation detail. + *******************************************************************************/ override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: AdornmentType) = encoder.encodeString(value.name) } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/FieldAdornment.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/FieldAdornment.kt index 3860f04..012766e 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/FieldAdornment.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/FieldAdornment.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,14 +24,12 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import com.kingsrook.qqq.frontend.android.core.model.MapOfStringAny import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class FieldAdornment( val type: AdornmentType, val values: MapOfStringAny? = emptyMap() -) -{ +) { } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppMetaData.kt index afc984a..72d59ab 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppMetaData.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,9 +24,8 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QAppMetaData( val name: String, @@ -36,8 +36,6 @@ data class QAppMetaData( val widgets: List? = emptyList(), val sections: List? = emptyList(), val keywords: List? = emptyList(), - // todo - not Any... val supplementalAppMetaData: Map = emptyMap() -) -{ +) { } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppNodeType.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppNodeType.kt index 3aaa09a..aa118e3 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppNodeType.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppNodeType.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -20,12 +21,10 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @kotlinx.serialization.Serializable(with = QAppNodeTypeSerializer::class) -enum class QAppNodeType -{ +enum class QAppNodeType { UNKNOWN, TABLE, PROCESS, @@ -33,10 +32,20 @@ enum class QAppNodeType APP, } -object QAppNodeTypeSerializer : kotlinx.serialization.KSerializer -{ +/******************************************************************************* + ** Keep unknown server enum values representable so additive metadata fails safely at the UI boundary. + *******************************************************************************/ +object QAppNodeTypeSerializer : kotlinx.serialization.KSerializer { override val descriptor = kotlinx.serialization.descriptors.PrimitiveSerialDescriptor("QAppNodeType", kotlinx.serialization.descriptors.PrimitiveKind.STRING) + + /******************************************************************************* + ** Map known wire names explicitly and preserve unsupported values through the enum's UNKNOWN member. + *******************************************************************************/ override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): QAppNodeType = decoder.decodeString().let { value -> QAppNodeType.entries.firstOrNull { it.name == value } ?: QAppNodeType.UNKNOWN } + + /******************************************************************************* + ** Write the stable enum wire name rather than a JVM implementation detail. + *******************************************************************************/ override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: QAppNodeType) = encoder.encodeString(value.name) } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppSection.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppSection.kt index 69b57be..6f3506a 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppSection.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppSection.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,9 +24,8 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QAppSection( val name: String, @@ -37,16 +37,12 @@ data class QAppSection( val apps: List? = emptyList(), val role: String? = null, val collapsedByDefault: Boolean? = null, -) -{ +) { - /*************************************************************************** - ** - ***************************************************************************/ - fun hasContent(): Boolean - { - if(apps?.isEmpty() != false && tables?.isEmpty() != false && processes?.isEmpty() != false && reports?.isEmpty() != false) - { + /******************************************************************************* + *******************************************************************************/ + fun hasContent(): Boolean { + if (apps?.isEmpty() != false && tables?.isEmpty() != false && processes?.isEmpty() != false && reports?.isEmpty() != false) { return (false) } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppTreeNode.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppTreeNode.kt index ca20e67..bab913d 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppTreeNode.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppTreeNode.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,9 +24,8 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** -** -***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QAppTreeNode( val name: String, @@ -34,7 +34,6 @@ data class QAppTreeNode( val children: List? = emptyList(), val icon: QIcon? = null, val keywords: List? = emptyList(), -) -{ +) { } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QBrandingMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QBrandingMetaData.kt index b8abdb3..25a3f8f 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QBrandingMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QBrandingMetaData.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,9 +23,8 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QBrandingMetaData( val companyName: String? = null, @@ -35,7 +35,6 @@ data class QBrandingMetaData( val accentColor: String? = null, val environmentBannerText: String? = null, val environmentBannerColor: String? = null, -) -{ +) { } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QComponentType.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QComponentType.kt index 32d8992..2c9df4f 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QComponentType.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QComponentType.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -20,12 +21,10 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @kotlinx.serialization.Serializable(with = QComponentTypeSerializer::class) -enum class QComponentType -{ +enum class QComponentType { UNKNOWN, HELP_TEXT, BULK_EDIT_FORM, @@ -41,10 +40,20 @@ enum class QComponentType HTML, } -object QComponentTypeSerializer : kotlinx.serialization.KSerializer -{ +/******************************************************************************* + ** Keep unknown server enum values representable so additive metadata fails safely at the UI boundary. + *******************************************************************************/ +object QComponentTypeSerializer : kotlinx.serialization.KSerializer { override val descriptor = kotlinx.serialization.descriptors.PrimitiveSerialDescriptor("QComponentType", kotlinx.serialization.descriptors.PrimitiveKind.STRING) + + /******************************************************************************* + ** Map known wire names explicitly and preserve unsupported values through the enum's UNKNOWN member. + *******************************************************************************/ override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): QComponentType = decoder.decodeString().let { value -> QComponentType.entries.firstOrNull { it.name == value } ?: QComponentType.UNKNOWN } + + /******************************************************************************* + ** Write the stable enum wire name rather than a JVM implementation detail. + *******************************************************************************/ override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: QComponentType) = encoder.encodeString(value.name) } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldMetaData.kt index de83604..ea60a56 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldMetaData.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,9 +25,8 @@ import com.kingsrook.qqq.frontend.android.core.model.AnyValueSerializer import com.kingsrook.qqq.frontend.android.core.model.QPossibleValue import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QFieldMetaData( val name: String, @@ -44,12 +44,13 @@ data class QFieldMetaData( val helpContents: List? = null, val isHidden: Boolean = false, val validation: FieldValidation? = null, - // todo - not Any... val behaviors: List?, -) -{ +) { } +/******************************************************************************* + ** Carry semantic field constraints so native validation follows the server contract. + *******************************************************************************/ @Serializable data class FieldValidation( val required: Boolean? = null, diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldType.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldType.kt index f00d24b..cdf7c08 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldType.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFieldType.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -20,12 +21,10 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @kotlinx.serialization.Serializable(with = QFieldTypeSerializer::class) -enum class QFieldType -{ +enum class QFieldType { UNKNOWN, BOOLEAN, STRING, @@ -41,10 +40,20 @@ enum class QFieldType BLOB, } -object QFieldTypeSerializer : kotlinx.serialization.KSerializer -{ +/******************************************************************************* + ** Keep unknown server enum values representable so additive metadata fails safely at the UI boundary. + *******************************************************************************/ +object QFieldTypeSerializer : kotlinx.serialization.KSerializer { override val descriptor = kotlinx.serialization.descriptors.PrimitiveSerialDescriptor("QFieldType", kotlinx.serialization.descriptors.PrimitiveKind.STRING) + + /******************************************************************************* + ** Map known wire names explicitly and preserve unsupported values through the enum's UNKNOWN member. + *******************************************************************************/ override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): QFieldType = decoder.decodeString().let { value -> QFieldType.entries.firstOrNull { it.name == value } ?: QFieldType.UNKNOWN } + + /******************************************************************************* + ** Write the stable enum wire name rather than a JVM implementation detail. + *******************************************************************************/ override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: QFieldType) = encoder.encodeString(value.name) } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFrontendComponent.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFrontendComponent.kt index 53fe836..f6f9d39 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFrontendComponent.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFrontendComponent.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,9 +25,8 @@ import com.kingsrook.qqq.frontend.android.core.model.AnyValueSerializer import com.kingsrook.qqq.frontend.android.core.model.MapOfStringAny import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QFrontendComponent( val type: QComponentType, diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFrontendStepMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFrontendStepMetaData.kt index 1724dc9..3b12983 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFrontendStepMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QFrontendStepMetaData.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,9 +24,8 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QFrontendStepMetaData( val name: String, @@ -36,7 +36,6 @@ data class QFrontendStepMetaData( val viewFields: List? = null, val recordListFields: List? = null, val helpContents: List? = null, -) -{ +) { } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QHelpContent.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QHelpContent.kt index f673fb2..9a4f39e 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QHelpContent.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QHelpContent.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,16 +23,14 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QHelpContent( val content: String, val format: String, val roles: Set = emptySet(), val contentAsText: String? = null -) -{ +) { } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QIcon.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QIcon.kt index 23a1f87..a7ec2ce 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QIcon.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QIcon.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,16 +23,14 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** -** -***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QIcon( val name: String? = null, val path: String? = null, val color: String? = null, val semanticName: String? = null, -) -{ +) { } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QInstance.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QInstance.kt index 4fea28e..2569d1a 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QInstance.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QInstance.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,20 +24,17 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** -** -***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QInstance( val tables: Map? = emptyMap(), val processes: Map? = emptyMap(), - // todo val reports: Map?, // todo val widgets: Map? = emptyMap(), val apps: Map? = emptyMap(), val appTree: List = emptyList(), val branding: QBrandingMetaData? = QBrandingMetaData(), val environmentValues: Map = emptyMap(), val helpContent: Map>? = emptyMap(), -) -{ -} \ No newline at end of file +) { +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QProcessMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QProcessMetaData.kt index 7cb5e99..a5b9c4b 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QProcessMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QProcessMetaData.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,9 +24,8 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QProcessMetaData( val name: String, @@ -36,6 +36,5 @@ data class QProcessMetaData( val frontendSteps: List? = emptyList(), val hasPermission: Boolean = false, val stepFlow: String = "LINEAR", -) -{ -} \ No newline at end of file +) { +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QProcessMetaDataWrapped.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QProcessMetaDataWrapped.kt index 1aacac0..235d86f 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QProcessMetaDataWrapped.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QProcessMetaDataWrapped.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,12 +23,10 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QProcessMetaDataWrapped( val process: QProcessMetaData -) -{ -} \ No newline at end of file +) { +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QTableMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QTableMetaData.kt index d13053f..628680b 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QTableMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QTableMetaData.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,16 +24,13 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QTableMetaData( val name: String, val label: String, val icon: QIcon? = null, val isHidden: Boolean = false, - // todo! all the other fields -) -{ -} \ No newline at end of file +) { +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QWidgetMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QWidgetMetaData.kt index 629b35f..1af3d74 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QWidgetMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QWidgetMetaData.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,16 +24,13 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QWidgetMetaData( val name: String, val label: String? = null, val icon: QIcon? = null, val type: String, - // todo! all the other fields -) -{ -} \ No newline at end of file +) { +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/Auth0AuthenticationMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/Auth0AuthenticationMetaData.kt index f8e5da5..10dc469 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/Auth0AuthenticationMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/Auth0AuthenticationMetaData.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,24 +24,27 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata.authentication import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class Auth0AuthenticationMetaData( override val name: String, val values: Auth0AuthenticationMetaDataValues -) : BaseAuthenticationMetaData -{ +) : BaseAuthenticationMetaData { override val type: String get() = TYPE - companion object - { + /******************************************************************************* + ** Keep the authentication type discriminator alongside the model consumed by polymorphic decoding. + *******************************************************************************/ + companion object { const val TYPE = "AUTH_0" } } +/******************************************************************************* + ** Carry public Auth0 client configuration without storing passwords or session credentials. + *******************************************************************************/ @Serializable data class Auth0AuthenticationMetaDataValues( val baseUrl: String, diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializer.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializer.kt index 85a3f02..f9b2b76 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializer.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializer.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -27,16 +28,12 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -/*************************************************************************** - ** - ***************************************************************************/ -object AuthenticationSerializer : JsonContentPolymorphicSerializer(BaseAuthenticationMetaData::class) -{ - /*************************************************************************** - ** - ***************************************************************************/ - override fun selectDeserializer(element: JsonElement) = when (element.jsonObject["type"]?.jsonPrimitive?.content) - { +/******************************************************************************* + *******************************************************************************/ +object AuthenticationSerializer : JsonContentPolymorphicSerializer(BaseAuthenticationMetaData::class) { + /******************************************************************************* + *******************************************************************************/ + override fun selectDeserializer(element: JsonElement) = when (element.jsonObject["type"]?.jsonPrimitive?.content) { Auth0AuthenticationMetaData.TYPE -> Auth0AuthenticationMetaData.serializer() OAuth2AuthenticationMetaData.TYPE -> OAuth2AuthenticationMetaData.serializer() FullyAnonymousAuthenticationMetaData.TYPE -> FullyAnonymousAuthenticationMetaData.serializer() @@ -45,5 +42,8 @@ object AuthenticationSerializer : JsonContentPolymorphicSerializer = emptyList(), val prompt: String = "", - val nonceValidation: String = "") + val nonceValidation: String = "" + ) + /******************************************************************************* + ** Advertise company sign-out independently with its explicitly allowed return callbacks. + *******************************************************************************/ @Serializable data class NativeSessionLogout(val flow: String = "", val postLogoutRedirectUris: List = emptyList()) - companion object - { + /******************************************************************************* + ** Keep protocol discriminators explicit so bearer grants cannot be mistaken for stored-session login. + *******************************************************************************/ + companion object { const val TYPE = "OAUTH2" const val SESSION_FLOW = "AUTHORIZATION_CODE_PKCE_SESSION" const val LOGOUT_FLOW = "OIDC_RP_INITIATED_LOGOUT" diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/TableBasedAuthenticationMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/TableBasedAuthenticationMetaData.kt index 70e2696..4e446c1 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/TableBasedAuthenticationMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/TableBasedAuthenticationMetaData.kt @@ -3,14 +3,17 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata.authentication import kotlinx.serialization.Serializable -/** Credentials are exchanged for a QQQ session; passwords are never persisted. */ +/******************************************************************************* + ** Credentials are exchanged for a QQQ session; passwords are never persisted. + *******************************************************************************/ @Serializable -data class TableBasedAuthenticationMetaData(override val name: String = "tableBased") : BaseAuthenticationMetaData -{ +data class TableBasedAuthenticationMetaData(override val name: String = "tableBased") : BaseAuthenticationMetaData { override val type: String get() = TYPE - companion object - { + /******************************************************************************* + ** Keep table-authentication identification independent of native browser capabilities. + *******************************************************************************/ + companion object { const val TYPE = "TABLE_BASED" } } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessMetaDataAdjustment.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessMetaDataAdjustment.kt index b1ae55a..1d3bf59 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessMetaDataAdjustment.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessMetaDataAdjustment.kt @@ -1,12 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.core.model.processes import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendStepMetaData import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class ProcessMetaDataAdjustment( val updatedFrontendStepList: List? = null, diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessStepResult.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessStepResult.kt index 6a8fb85..63cf6a0 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessStepResult.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessStepResult.kt @@ -1,13 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.core.model.processes import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.AuthenticationSerializer import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable(with = ProcessStepResultSerializer::class) -sealed class ProcessStepResult() -{ +sealed class ProcessStepResult() { abstract val processUUID: String; -} \ No newline at end of file +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessStepResultSerializer.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessStepResultSerializer.kt index df535d8..b4730c1 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessStepResultSerializer.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/ProcessStepResultSerializer.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -27,16 +28,12 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -/*************************************************************************** - ** - ***************************************************************************/ -class ProcessStepResultSerializer : JsonContentPolymorphicSerializer(ProcessStepResult::class) -{ - /*************************************************************************** - ** - ***************************************************************************/ - override fun selectDeserializer(element: JsonElement) = when (element.jsonObject["type"]?.jsonPrimitive?.content) - { +/******************************************************************************* + *******************************************************************************/ +class ProcessStepResultSerializer : JsonContentPolymorphicSerializer(ProcessStepResult::class) { + /******************************************************************************* + *******************************************************************************/ + override fun selectDeserializer(element: JsonElement) = when (element.jsonObject["type"]?.jsonPrimitive?.content) { "COMPLETE" -> QJobComplete.serializer() "JOB_STARTED" -> QJobStarted.serializer() "RUNNING" -> QJobRunning.serializer() diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobComplete.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobComplete.kt index d090c58..ff81554 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobComplete.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobComplete.kt @@ -1,11 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.core.model.processes import com.kingsrook.qqq.frontend.android.core.model.MapOfStringAny import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QJobComplete( override val processUUID: String, diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobError.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobError.kt index beea6a6..d9a1980 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobError.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobError.kt @@ -1,10 +1,30 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.core.model.processes import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QJobError( override val processUUID: String, diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobRunning.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobRunning.kt index 24dfc34..bc9997d 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobRunning.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobRunning.kt @@ -1,10 +1,30 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.core.model.processes import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QJobRunning( override val processUUID: String, diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobStarted.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobStarted.kt index dd83e89..f131ac5 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobStarted.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobStarted.kt @@ -1,10 +1,30 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.core.model.processes import kotlinx.serialization.Serializable -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Serializable data class QJobStarted( override val processUUID: String, diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/widgets/WidgetBlock.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/widgets/WidgetBlock.kt index ce036c6..eed422b 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/widgets/WidgetBlock.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/widgets/WidgetBlock.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,6 +25,9 @@ package com.kingsrook.qqq.frontend.android.core.model.widgets import com.kingsrook.qqq.frontend.android.core.model.MutableMapOfStringAny import kotlinx.serialization.Serializable +/******************************************************************************* + ** Represent server widget structure without selecting client classes from arbitrary payload values. + *******************************************************************************/ @Serializable data class WidgetBlock( val blockId: String, @@ -34,54 +38,66 @@ data class WidgetBlock( val styles: MutableMapOfStringAny = mutableMapOf(), val values: MutableMapOfStringAny = mutableMapOf(), var subBlocks: List = emptyList(), -) -{ - companion object - { - fun fromMap(param: Any?): WidgetBlock? - { - if(param is Map<*, *>) - { - fun mapGetOrElse(map: Map, key: String, elseValue: String? = null): String? - { - if(map[key] != null) - { +) { + /******************************************************************************* + ** Centralize conversion of legacy map-shaped widget blocks into the semantic block model. + *******************************************************************************/ + companion object { + /******************************************************************************* + ** Rebuild nested widget structure only from map/list input, retaining unknown block types for explicit UI handling. + *******************************************************************************/ + fun fromMap(param: Any?): WidgetBlock? { + if (param is Map<*, *>) { + /******************************************************************************* + ** Preserve absent optional string values while adapting the legacy generic widget map. + *******************************************************************************/ + fun mapGetOrElse(map: Map<*, *>, key: String, elseValue: String? = null): String? { + if (map[key] != null) { return map[key].toString() } return elseValue } - val map: Map = param as Map - val blockId = if(map["blockId"] != null) "${map["blockId"]}" else "" - val blockType = if(map["blockType"] != null) "${map["blockType"]}" else "" - val conditional = if(map["conditional"] != null) "${map["conditional"]}" else null - val layout = if(map["layout"] != null) "${map["layout"]}" else null + val map = param + val blockId = if (map["blockId"] != null) "${map["blockId"]}" else "" + val blockType = if (map["blockType"] != null) "${map["blockType"]}" else "" + val conditional = if (map["conditional"] != null) "${map["conditional"]}" else null + val layout = if (map["layout"] != null) "${map["layout"]}" else null val modalMode = mapGetOrElse(map, "modalMode") - val mapStyles = map["styles"] - val styles = if(mapStyles is Map<*, *>) mapStyles else mutableMapOf() + /******************************************************************************* + ** Copy validated JSON property names into owned mutable state; input maps + ** may be immutable or shared by another widget's decoded metadata. + *******************************************************************************/ + fun copyStringMap(value: Any?): MutableMapOfStringAny { + val source = value as? Map<*, *> ?: return mutableMapOf() + val result = mutableMapOf() + source.forEach { (key, itemValue) -> + require(key is String) { "Widget property names must be strings" } + result[key] = itemValue + } + return result + } - val mapValues = map["values"] - val values = if(mapValues is Map<*, *>) mapValues else mutableMapOf() + val styles = copyStringMap(map["styles"]) + val values = copyStringMap(map["values"]) val mapSubBlocks = map["subBlocks"] var subBlocks = emptyList() - if(mapSubBlocks is List<*>) - { + if (mapSubBlocks is List<*>) { subBlocks = mutableListOf() mapSubBlocks.forEach() { subBlock -> val subWidgetBlock = fromMap(subBlock) - if(subWidgetBlock != null) - { + if (subWidgetBlock != null) { subBlocks.add(subWidgetBlock) } } } - return WidgetBlock(blockId, blockType, conditional, layout, modalMode, styles as MutableMapOfStringAny, values as MutableMapOfStringAny, subBlocks) + return WidgetBlock(blockId, blockType, conditional, layout, modalMode, styles, values, subBlocks) } return (null) diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/utils/StringExtensions.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/utils/StringExtensions.kt index 11b49ee..ad24650 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/utils/StringExtensions.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/utils/StringExtensions.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,12 +23,11 @@ package com.kingsrook.qqq.frontend.android.core.utils import kotlin.math.min -/*************************************************************************** +/******************************************************************************* ** string extension function to safely get firstN chars (avoid index out of ** bounds) - ***************************************************************************/ -fun String.firstN(n: Int): String -{ + *******************************************************************************/ +fun String.firstN(n: Int): String { val actualN = min(n, this.length) return this.substring(0, actualN) -} \ No newline at end of file +} diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/DeferredAuthenticationTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/DeferredAuthenticationTest.kt index 491c24c..7d9eb20 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/DeferredAuthenticationTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/DeferredAuthenticationTest.kt @@ -9,61 +9,91 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Test import java.util.concurrent.TimeUnit -class DeferredAuthenticationTest -{ - @Test fun passwordCookieAfterSwitchIsNeverAdopted() = delayedResponse(password = true, cookie = true) - @Test fun passwordBodyAfterSwitchIsNeverAdopted() = delayedResponse(password = true, cookie = false) - @Test fun auth0CookieAfterSwitchIsNeverAdopted() = delayedResponse(password = false, cookie = true) - @Test fun auth0BodyAfterSwitchIsNeverAdopted() = delayedResponse(password = false, cookie = false) +/******************************************************************************* + ** + *******************************************************************************/ +class DeferredAuthenticationTest { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun passwordCookieAfterSwitchIsNeverAdopted() = delayedResponse(password = true, cookie = true) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun passwordBodyAfterSwitchIsNeverAdopted() = delayedResponse(password = true, cookie = false) + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun auth0CookieAfterSwitchIsNeverAdopted() = delayedResponse(password = false, cookie = true) + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun auth0BodyAfterSwitchIsNeverAdopted() = delayedResponse(password = false, cookie = false) + + /******************************************************************************* + ** + *******************************************************************************/ private fun delayedResponse(password: Boolean, cookie: Boolean) = runBlocking() { - MockWebServer().use { first -> MockWebServer().use { second -> - val origin = first.url("/").toString() - val otherOrigin = second.url("/").toString() - first.enqueue(MockResponse().setBody(VERSION_FIXTURE)) - val response = MockResponse().setBody(if(cookie) "{}" else """{"uuid":"abandoned-session"}""") - .setBodyDelay(500, TimeUnit.MILLISECONDS) - if(cookie) response.addHeader("Set-Cookie", "sessionUUID=abandoned-session; HttpOnly; Path=/") - first.enqueue(response) - first.enqueue(MockResponse().setBody("{}").addHeader("Set-Cookie", "sessionUUID=; Path=/; Max-Age=0")) - first.enqueue(MockResponse().setBody("{}")) - second.enqueue(MockResponse().setBody(VERSION_FIXTURE)) - second.enqueue(MockResponse().setBody("{}")) - val repository = NetworkQQQRepository(origin) - repository.setEnvironment(Environment("first", origin)) - val pending = async(Dispatchers.IO) { - if(password) repository.loginWithPassword("operator", "synthetic-password") - else repository.manageSession(ManageSessionRequest(accessToken = "synthetic-provider-token")) + MockWebServer().use { first -> + MockWebServer().use { second -> + val origin = first.url("/").toString() + val otherOrigin = second.url("/").toString() + first.enqueue(MockResponse().setBody(VERSION_FIXTURE)) + val response = MockResponse().setBody(if (cookie) "{}" else """{"uuid":"abandoned-session"}""") + .setBodyDelay(500, TimeUnit.MILLISECONDS) + if (cookie) response.addHeader("Set-Cookie", "sessionUUID=abandoned-session; HttpOnly; Path=/") + first.enqueue(response) + first.enqueue(MockResponse().setBody("{}").addHeader("Set-Cookie", "sessionUUID=; Path=/; Max-Age=0")) + first.enqueue(MockResponse().setBody("{}")) + second.enqueue(MockResponse().setBody(VERSION_FIXTURE)) + second.enqueue(MockResponse().setBody("{}")) + val repository = NetworkQQQRepository(origin) + repository.setEnvironment(Environment("first", origin)) + val pending = async(Dispatchers.IO) { + if (password) repository.loginWithPassword("operator", "synthetic-password") + else repository.manageSession(ManageSessionRequest(accessToken = "synthetic-provider-token")) + } + withContext(Dispatchers.IO) { + assertEquals("/qqq/v1/version", first.takeRequest(5, TimeUnit.SECONDS)?.path) + val login = requireNotNull(first.takeRequest(5, TimeUnit.SECONDS)) + assertEquals("/qqq/v1/manageSession", login.path) + assertNull(login.getHeader("Cookie")) + } + repository.setEnvironment(Environment("second", otherOrigin)) + repository.setSessionUUID("current-second-session") + assertEquals("abandoned-session", pending.await().uuid) + repository.discardNativeSession(origin, "abandoned-session") + val discard = requireNotNull(first.takeRequest(5, TimeUnit.SECONDS)) + assertEquals("/qqq/v1/logout", discard.path) + assertEquals("sessionUUID=abandoned-session", discard.getHeader("Cookie")) + assertNull(discard.getHeader("Authorization")) + repository.getMetaData("test", "1", "test", "1") + assertEquals("/qqq/v1/version", second.takeRequest(5, TimeUnit.SECONDS)?.path) + val current = requireNotNull(second.takeRequest(5, TimeUnit.SECONDS)) + assertEquals("sessionUUID=current-second-session", current.getHeader("Cookie")) + assertNull(current.getHeader("Authorization")) + assertFalse(current.body.readUtf8().contains("synthetic-provider-token")) + repository.setEnvironment(Environment("first", origin)) + repository.getMetaData("test", "1", "test", "1") + assertNull( + "Returning to the original server must not resurrect the abandoned cookie", + requireNotNull(first.takeRequest(5, TimeUnit.SECONDS)).getHeader("Cookie") + ) } - withContext(Dispatchers.IO) { - assertEquals("/qqq/v1/version", first.takeRequest(5, TimeUnit.SECONDS)?.path) - val login = requireNotNull(first.takeRequest(5, TimeUnit.SECONDS)) - assertEquals("/qqq/v1/manageSession", login.path) - assertNull(login.getHeader("Cookie")) - } - repository.setEnvironment(Environment("second", otherOrigin)) - repository.setSessionUUID("current-second-session") - assertEquals("abandoned-session", pending.await().uuid) - repository.discardNativeSession(origin, "abandoned-session") - val discard = requireNotNull(first.takeRequest(5, TimeUnit.SECONDS)) - assertEquals("/qqq/v1/logout", discard.path) - assertEquals("sessionUUID=abandoned-session", discard.getHeader("Cookie")) - assertNull(discard.getHeader("Authorization")) - repository.getMetaData("test", "1", "test", "1") - assertEquals("/qqq/v1/version", second.takeRequest(5, TimeUnit.SECONDS)?.path) - val current = requireNotNull(second.takeRequest(5, TimeUnit.SECONDS)) - assertEquals("sessionUUID=current-second-session", current.getHeader("Cookie")) - assertNull(current.getHeader("Authorization")) - assertFalse(current.body.readUtf8().contains("synthetic-provider-token")) - repository.setEnvironment(Environment("first", origin)) - repository.getMetaData("test", "1", "test", "1") - assertNull("Returning to the original server must not resurrect the abandoned cookie", - requireNotNull(first.takeRequest(5, TimeUnit.SECONDS)).getHeader("Cookie")) - } } + } } } diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeLogoutRepositoryTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeLogoutRepositoryTest.kt index a0ccaa2..75b0c02 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeLogoutRepositoryTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeLogoutRepositoryTest.kt @@ -9,17 +9,25 @@ import kotlinx.coroutines.Dispatchers import kotlinx.serialization.json.Json import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -/** Exercise the actual additive wire operation, cookie isolation and secret-safe diagnostics. */ -class NativeLogoutRepositoryTest -{ +/******************************************************************************* + ** Exercise the actual additive wire operation, cookie isolation and secret-safe diagnostics. + *******************************************************************************/ +class NativeLogoutRepositoryTest { private val version = VERSION_FIXTURE.replace("1.8.0", "1.10.0").replace("\"widgets\"", "\"widgets\",\"nativeSessionLogout\"") private val callback = "io.qrun.test.oauth://callback/oauth2redirect" private val response = """{"nativeLogoutData":{"issuer":"https://identity.example","endSessionEndpoint":"https://identity.example/end-session","postLogoutRedirectUri":"$callback","idTokenHint":"synthetic.logout.hint.canary"}}""" - @Test fun explicitLogoutReturnsTransientHintAndCannotAdoptResponseCookies() = runBlocking() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun explicitLogoutReturnsTransientHintAndCannotAdoptResponseCookies() = runBlocking() { MockWebServer().use { server -> server.enqueue(MockResponse().setBody(version)) @@ -46,7 +54,11 @@ class NativeLogoutRepositoryTest } } - @Test fun oldServerReceivesOnlyOrdinaryLogoutAndNoOptionalBody() = runBlocking() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun oldServerReceivesOnlyOrdinaryLogoutAndNoOptionalBody() = runBlocking() { MockWebServer().use { server -> server.enqueue(MockResponse().setBody(VERSION_FIXTURE)) @@ -64,11 +76,16 @@ class NativeLogoutRepositoryTest } } - @Test fun failureAndMalformedMaterialDropCookiesWithoutEmittingResponseSecrets() = runBlocking() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun failureAndMalformedMaterialDropCookiesWithoutEmittingResponseSecrets() = runBlocking() { - for(reply in listOf(MockResponse().setResponseCode(503).setBody("""{"message":"synthetic.logout.hint.canary"}"""), - MockResponse().setBody("""{"nativeLogoutData":{"idTokenHint":"synthetic.logout.hint.canary"}}"""))) - { + for (reply in listOf( + MockResponse().setResponseCode(503).setBody("""{"message":"synthetic.logout.hint.canary"}"""), + MockResponse().setBody("""{"nativeLogoutData":{"idTokenHint":"synthetic.logout.hint.canary"}}""") + )) { MockWebServer().use { server -> server.enqueue(MockResponse().setBody(version)) server.enqueue(reply) @@ -87,33 +104,44 @@ class NativeLogoutRepositoryTest } } - @Test fun logoutRequiresBothVersionAndFeature() - { - for(value in listOf(UiApiVersion("1.9.0", listOf("nativeSessionLogout")), UiApiVersion("1.10.0"), - UiApiVersion("2.0.0", listOf("nativeSessionLogout")))) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun logoutRequiresBothVersionAndFeature() { + for (value in listOf( + UiApiVersion("1.9.0", listOf("nativeSessionLogout")), UiApiVersion("1.10.0"), + UiApiVersion("2.0.0", listOf("nativeSessionLogout")) + )) assertTrue(runCatching { UiApiCompatibility.validateNativeSessionLogout(value) }.isFailure) UiApiCompatibility.validateNativeSessionLogout(UiApiVersion("1.10.0", listOf("nativeSessionLogout"))) } - @Test fun lateResponseRemainsScopedToOriginalServerWithoutClearingNewServerCookie() = runBlocking() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun lateResponseRemainsScopedToOriginalServerWithoutClearingNewServerCookie() = runBlocking() { - MockWebServer().use { first -> MockWebServer().use { second -> - first.enqueue(MockResponse().setBody(version)) - first.enqueue(MockResponse().setBody(response).setBodyDelay(150, java.util.concurrent.TimeUnit.MILLISECONDS)) - second.enqueue(MockResponse().setBody(VERSION_FIXTURE)) - second.enqueue(MockResponse().setBody("{}")) - val origin = first.url("/").toString() - val repository = NetworkQQQRepository(origin) - repository.setEnvironment(Environment("first", origin)) - repository.setSessionUUID("old-session") - val pending = async(Dispatchers.IO) { repository.logoutWithProvider(callback) } - first.takeRequest(); first.takeRequest() - repository.setEnvironment(Environment("second", second.url("/").toString())) - repository.setSessionUUID("new-session") - pending.await() - repository.getMetaData("test", "1", "test", "1") - second.takeRequest() - assertEquals("sessionUUID=new-session", second.takeRequest().getHeader("Cookie")) - } } + MockWebServer().use { first -> + MockWebServer().use { second -> + first.enqueue(MockResponse().setBody(version)) + first.enqueue(MockResponse().setBody(response).setBodyDelay(150, java.util.concurrent.TimeUnit.MILLISECONDS)) + second.enqueue(MockResponse().setBody(VERSION_FIXTURE)) + second.enqueue(MockResponse().setBody("{}")) + val origin = first.url("/").toString() + val repository = NetworkQQQRepository(origin) + repository.setEnvironment(Environment("first", origin)) + repository.setSessionUUID("old-session") + val pending = async(Dispatchers.IO) { repository.logoutWithProvider(callback) } + first.takeRequest(); first.takeRequest() + repository.setEnvironment(Environment("second", second.url("/").toString())) + repository.setSessionUUID("new-session") + pending.await() + repository.getMetaData("test", "1", "test", "1") + second.takeRequest() + assertEquals("sessionUUID=new-session", second.takeRequest().getHeader("Cookie")) + } + } } } diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeSessionLoginTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeSessionLoginTest.kt index 615a142..acc832b 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeSessionLoginTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeSessionLoginTest.kt @@ -10,18 +10,28 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -class NativeSessionLoginTest -{ +/******************************************************************************* + ** + *******************************************************************************/ +class NativeSessionLoginTest { private val version = VERSION_FIXTURE.replace("1.8.0", "1.10.0").replace("\"widgets\"", "\"widgets\",\"nativeSessionLogin\",\"nativeSessionNonceValidation\"") - private val request = ManageSessionRequest(code = "synthetic-code", codeVerifier = "v".repeat(64), + private val request = ManageSessionRequest( + code = "synthetic-code", codeVerifier = "v".repeat(64), redirectUri = "io.qrun.mobile.scanner.oauth://callback/oauth2redirect", - authenticationFlow = OAuth2AuthenticationMetaData.SESSION_FLOW, expectedNonce = "original-nonce-canary-1234") + authenticationFlow = OAuth2AuthenticationMetaData.SESSION_FLOW, expectedNonce = "original-nonce-canary-1234" + ) - @Test fun metadataKeepsNativeBearerDistinctFromAdvertisedSessionFlow() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun metadataKeepsNativeBearerDistinctFromAdvertisedSessionFlow() { val metadata = Json.decodeFromString("""{"type":"OAUTH2","name":"organization","values":{"clientId":"server-client","baseUrl":"https://id.example/discovery-base/","scopes":"openid profile","nativeClientId":"public-bearer-client","nativeSessionLogin":{"flow":"AUTHORIZATION_CODE_PKCE_SESSION","codeChallengeMethod":"S256","prompt":"login","nonceValidation":"REQUIRED","issuer":"https://id.example/","discoveryUrl":"https://id.example/application/o/company/.well-known/openid-configuration","redirectUris":["io.qrun.mobile.scanner.oauth://callback/oauth2redirect"]}}}""") as OAuth2AuthenticationMetaData assertEquals("server-client", metadata.values.clientId) assertEquals("public-bearer-client", metadata.values.nativeClientId) @@ -40,17 +50,26 @@ class NativeSessionLoginTest assertFalse(Json.encodeToString(ManageSessionRequest(accessToken = "synthetic-legacy-token")).contains("expectedNonce")) } - @Test fun featureAndVersionAreRequiredTogether() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun featureAndVersionAreRequiredTogether() { val supported = Json.decodeFromString(version) UiApiCompatibility.validateNativeSessionLogin(supported) - for(unsupported in listOf(supported.copy(apiVersion = "1.8.9"), supported.copy(apiVersion = "1.9.0"), supported.copy(apiVersion = "2.0.0"), + for (unsupported in listOf( + supported.copy(apiVersion = "1.8.9"), supported.copy(apiVersion = "1.9.0"), supported.copy(apiVersion = "2.0.0"), supported.copy(apiVersion = "1.10.0-preview"), supported.copy(features = supported.features - "nativeSessionLogin"), - supported.copy(features = supported.features - "nativeSessionNonceValidation"))) + supported.copy(features = supported.features - "nativeSessionNonceValidation") + )) assertTrue(runCatching { UiApiCompatibility.validateNativeSessionLogin(unsupported) }.exceptionOrNull() is UiApiCompatibilityException) } - @Test fun exchangeCookieIsDeferredAndDiscardNeverChangesCurrentSession() = runBlocking { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun exchangeCookieIsDeferredAndDiscardNeverChangesCurrentSession() = runBlocking { MockWebServer().use { server -> val origin = server.url("/").toString() server.enqueue(MockResponse().setBody(version)) @@ -78,7 +97,11 @@ class NativeSessionLoginTest } } - @Test fun missingFeaturePreventsSendingTheAuthorizationCode() = runBlocking { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun missingFeaturePreventsSendingTheAuthorizationCode() = runBlocking { MockWebServer().use { server -> server.enqueue(MockResponse().setBody(VERSION_FIXTURE)) val origin = server.url("/").toString() @@ -92,18 +115,26 @@ class NativeSessionLoginTest } } - @Test fun invalidOriginalNoncePreventsAnyNetworkRequest() = runBlocking { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun invalidOriginalNoncePreventsAnyNetworkRequest() = runBlocking { MockWebServer().use { server -> val origin = server.url("/").toString() val repository = NetworkQQQRepository(origin) repository.setEnvironment(Environment("fixture", origin)) - for(value in listOf(null, "", "n".repeat(15), "n".repeat(513), "not valid nonce", "n".repeat(20) + "=")) + for (value in listOf(null, "", "n".repeat(15), "n".repeat(513), "not valid nonce", "n".repeat(20) + "=")) assertTrue(runCatching { repository.exchangeNativeSession(request.copy(expectedNonce = value)) }.exceptionOrNull() is IllegalArgumentException) assertEquals(0, server.requestCount) } } - @Test fun rejectedNonceNeverRetriesAnUnprotectedExchange() = runBlocking { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun rejectedNonceNeverRetriesAnUnprotectedExchange() = runBlocking { MockWebServer().use { server -> server.enqueue(MockResponse().setBody(version)) server.enqueue(MockResponse().setResponseCode(401).setBody("""{"code":"AUTHENTICATION_FAILED","message":"Sign-in failed"}""")) diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt index 659f793..de26af6 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt @@ -11,9 +11,13 @@ import org.junit.Assert.assertTrue import org.junit.Assert.assertFalse import org.junit.Test -/** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped. */ -class NetworkQQQRepositoryTest -{ +/******************************************************************************* + ** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped. + *******************************************************************************/ +class NetworkQQQRepositoryTest { + /******************************************************************************* + ** + *******************************************************************************/ @Test fun clearedOptionalFieldsRemainExplicitNullsOnTheWire() = runBlocking() { @@ -36,6 +40,9 @@ class NetworkQQQRepositoryTest } } + /******************************************************************************* + ** + *******************************************************************************/ @Test fun reportsOnlyStatusAndTimingAndObserverFailuresCannotBreakRequests() = runBlocking() { @@ -57,6 +64,9 @@ class NetworkQQQRepositoryTest } } + /******************************************************************************* + ** + *******************************************************************************/ @Test fun transportFailureReportsNullStatusWithoutExceptionMessage() = runBlocking() { @@ -73,6 +83,9 @@ class NetworkQQQRepositoryTest assertFalse(outcomes.toString().contains("localhost")) } + /******************************************************************************* + ** + *******************************************************************************/ @Test fun logoutRevokesServerSessionBeforeDroppingTheCookie() = runBlocking() { @@ -96,6 +109,9 @@ class NetworkQQQRepositoryTest } } + /******************************************************************************* + ** + *******************************************************************************/ @Test fun capturesHttpOnlySessionAndDoesNotSendItToAnotherServer() = runBlocking() { @@ -104,14 +120,16 @@ class NetworkQQQRepositoryTest first.start() second.start() first.enqueue(MockResponse().setBody(VERSION_FIXTURE)) - first.enqueue(MockResponse().setHeader("Content-Type", "application/json") - .addHeader("Set-Cookie", "sessionUUID=synthetic-session; HttpOnly; Path=/").setBody("{\"values\":{}}")) + first.enqueue( + MockResponse().setHeader("Content-Type", "application/json") + .addHeader("Set-Cookie", "sessionUUID=synthetic-session; HttpOnly; Path=/").setBody("{\"values\":{}}") + ) first.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}")) second.enqueue(MockResponse().setBody(VERSION_FIXTURE)) second.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}")) val origin = first.url("/").toString() val repository = NetworkQQQRepository(origin) { server -> - if(server == origin) mapOf("X-QQQ-Device-Id" to "test-device") else emptyMap() + if (server == origin) mapOf("X-QQQ-Device-Id" to "test-device") else emptyMap() } repository.setEnvironment(Environment("first", origin)) val login = repository.loginWithPassword("demo", "synthetic-password") diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibilityTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibilityTest.kt index bde938c..1f1b45c 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibilityTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibilityTest.kt @@ -2,19 +2,33 @@ package com.kingsrook.qqq.frontend.android.core.controllers import com.kingsrook.qqq.frontend.android.core.model.Environment -import com.kingsrook.qqq.frontend.android.core.model.metadata.* +import com.kingsrook.qqq.frontend.android.core.model.metadata.AdornmentType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QAppNodeType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QComponentType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test internal const val VERSION_FIXTURE = """{"apiVersion":"1.8.0","features":["version","metaData","processes","processCancel","extensions","widgets"],"clientChannels":["web","api","android"],"extensions":[{"name":"mobile-scanners","version":"1.1.0","pathPrefix":"/qbit/mobile-scanners"}]}""" -class UiApiCompatibilityTest -{ - @Test fun incompatibleDiscoveryNeverSendsCredentialsOrMetadata() = runBlocking() +/******************************************************************************* + ** + *******************************************************************************/ +class UiApiCompatibilityTest { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun incompatibleDiscoveryNeverSendsCredentialsOrMetadata() = runBlocking() { val incompatible = listOf( VERSION_FIXTURE.replace("1.8.0", "2.0.0"), @@ -27,7 +41,7 @@ class UiApiCompatibilityTest VERSION_FIXTURE.replace("/qbit/mobile-scanners", "/evil"), VERSION_FIXTURE.replace("\"android\"", "\"future-client\""), ) - for(payload in incompatible) MockWebServer().use { server -> + for (payload in incompatible) MockWebServer().use { server -> server.enqueue(MockResponse().setBody(payload)) val origin = server.url("/").toString() val gate = UiApiCompatibility(origin, true) @@ -45,8 +59,11 @@ class UiApiCompatibilityTest } } - @Test fun acceptsAdditiveVersionFeaturesAndUnknownMetadataEnums() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun acceptsAdditiveVersionFeaturesAndUnknownMetadataEnums() { val json = Json { ignoreUnknownKeys = true } val version = json.decodeFromString(VERSION_FIXTURE.replace("1.8.0", "1.99.9").replace("1.1.0", "1.12.1")) UiApiCompatibility.validate(version.copy(features = version.features + "futureFeature"), true) @@ -58,21 +75,29 @@ class UiApiCompatibilityTest assertEquals(AdornmentType.UNKNOWN, json.decodeFromString("\"FUTURE_ADORNMENT\"")) } - @Test fun discoveryIsCachedOnlyForItsOriginAndCarriesNoCookies() - { - MockWebServer().use { first -> MockWebServer().use { second -> - first.enqueue(MockResponse().setBody(VERSION_FIXTURE).addHeader("Set-Cookie", "sessionUUID=untrusted; Path=/")) - second.enqueue(MockResponse().setBody(VERSION_FIXTURE)) - val gate = UiApiCompatibility(first.url("/").toString(), true) - gate.verify(); gate.verify() - UiApiCompatibility(second.url("/").toString(), true).verify() - assertEquals(1, first.requestCount) - assertNull(second.takeRequest().getHeader("Cookie")) - } } + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun discoveryIsCachedOnlyForItsOriginAndCarriesNoCookies() { + MockWebServer().use { first -> + MockWebServer().use { second -> + first.enqueue(MockResponse().setBody(VERSION_FIXTURE).addHeader("Set-Cookie", "sessionUUID=untrusted; Path=/")) + second.enqueue(MockResponse().setBody(VERSION_FIXTURE)) + val gate = UiApiCompatibility(first.url("/").toString(), true) + gate.verify(); gate.verify() + UiApiCompatibility(second.url("/").toString(), true).verify() + assertEquals(1, first.requestCount) + assertNull(second.takeRequest().getHeader("Cookie")) + } + } } - @Test fun nullExtensionsAreAnEmptyAdvertisementRatherThanMalformedDiscovery() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun nullExtensionsAreAnEmptyAdvertisementRatherThanMalformedDiscovery() { val payload = VERSION_FIXTURE.replace("\"extensions\":[{\"name\":\"mobile-scanners\",\"version\":\"1.1.0\",\"pathPrefix\":\"/qbit/mobile-scanners\"}]", "\"extensions\":null") MockWebServer().use { server -> server.enqueue(MockResponse().setBody(payload)) @@ -88,7 +113,11 @@ class UiApiCompatibilityTest } } - @Test fun cancellationUsesPublishedOperationAndSemanticErrorsRemainStructured() = runBlocking() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun cancellationUsesPublishedOperationAndSemanticErrorsRemainStructured() = runBlocking() { MockWebServer().use { server -> server.enqueue(MockResponse().setBody(VERSION_FIXTURE)) @@ -116,8 +145,11 @@ class UiApiCompatibilityTest } } - @Test fun metadataMediaReferencesResolveWithoutInventingApiRoutes() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun metadataMediaReferencesResolveWithoutInventingApiRoutes() { val repository = NetworkQQQRepository("https://server.example/") assertEquals("https://cdn.example/logo.png", repository.getURI("https://cdn.example/logo.png")) assertEquals("https://server.example/qqq/v1/avatars/test", repository.getURI("/qqq/v1/avatars/test")) diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/GenericSerializingTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/GenericSerializingTest.kt index be4368c..1c5b08f 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/GenericSerializingTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/GenericSerializingTest.kt @@ -1,3 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.core.model @@ -12,28 +33,26 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class GenericSerializingTest -{ - /*************************************************************************** + *******************************************************************************/ +class GenericSerializingTest { + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testThatCameWithCode() - { - /*************************************************************************** + fun testThatCameWithCode() { + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Serializable data class MySerializableData( val mapValues: Map ) - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Serializable @ExperimentalSerializationApi data class OtherDataClass( @@ -68,10 +87,11 @@ class GenericSerializingTest assertEquals(mapStringAny, decoded2) } - /** Business discriminator keys remain data, including class-like strings and nested nulls. */ + /******************************************************************************* + ** Business discriminator keys remain data, including class-like strings and nested nulls. + *******************************************************************************/ @Test - fun typeAndValueObjectsNeverSelectRuntimeClasses() - { + fun typeAndValueObjectsNeverSelectRuntimeClasses() { val payload = """{"processUUID":"p","values":{"weight":{"type":"weight","value":12},"nested":{"type":"java.lang.Runtime","value":{"type":7,"value":null}}}}""" val decoded = Json.decodeFromString(payload) assertEquals(mapOf("type" to "weight", "value" to 12L), decoded.values["weight"]) @@ -80,17 +100,16 @@ class GenericSerializingTest assertEquals(decoded, roundTrip) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testQFrontendComponent() - { + fun testQFrontendComponent() { val component = Json.decodeFromString( """ { "type": "HELP_TEXT", - "values": + "values": { "helpText": "Lorem ipsum" } @@ -102,21 +121,20 @@ class GenericSerializingTest assertEquals(1, component.values.size) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testQJobCompleteWithArray() - { + fun testQJobCompleteWithArray() { val jobComplete = Json.decodeFromString( """ { "processUUID": "123-123-123-123", "nextStep": "Underpants", - "values": + "values": { "someString": "Nylon", - "someArray": + "someArray": [ {"foo": "bar", "one": 1} ], diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/MobileProtocolTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/MobileProtocolTest.kt index ad6ac52..be9295b 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/MobileProtocolTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/MobileProtocolTest.kt @@ -14,28 +14,35 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -/** Fixtures cover wire differences observed against the current QQQ demo server. */ -class MobileProtocolTest -{ +/******************************************************************************* + ** Fixtures cover wire differences observed against the current QQQ demo server. + *******************************************************************************/ +class MobileProtocolTest { private val json = Json { ignoreUnknownKeys = true } + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun acceptsTableBasedAuthenticationMetadata() - { + fun acceptsTableBasedAuthenticationMetadata() { val metadata = json.decodeFromString("""{"name":"users","type":"TABLE_BASED","values":{}}""") assertTrue(metadata is TableBasedAuthenticationMetaData) } + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun terminalProcessCompletionMayOmitNextStep() - { + fun terminalProcessCompletionMayOmitNextStep() { val result = json.decodeFromString("""{"type":"COMPLETE","processUUID":"test-process","values":{}}""") assertEquals("", (result as QJobComplete).nextStep) } + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun rendersContractCapturedFromTheLiveDemo() - { + fun rendersContractCapturedFromTheLiveDemo() { val metadata = json.decodeFromString(fixture("metadata")) assertTrue(metadata.apps!!.containsKey("mobileScanApp")) val process = json.decodeFromString(fixture("process")) @@ -47,7 +54,9 @@ class MobileProtocolTest assertEquals("Scan a barcode", composite.subBlocks.single().values["title"]) } - /** These sanitized fixtures contain demo metadata only, never sessions or device tokens. */ + /******************************************************************************* + ** These sanitized fixtures contain demo metadata only, never sessions or device tokens. + *******************************************************************************/ private fun fixture(name: String): String = requireNotNull(javaClass.getResourceAsStream("/mobile-contract/$name.json")).bufferedReader().use { it.readText() } } diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppSectionTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppSectionTest.kt index 87da702..d0388f8 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppSectionTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/metadata/QAppSectionTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,20 +23,19 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata -import org.junit.Assert.* +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class QAppSectionTest -{ - /*************************************************************************** + *******************************************************************************/ +class QAppSectionTest { + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testHasContent() - { + fun testHasContent() { assertFalse(QAppSection("test", "Test").hasContent()) assertFalse(QAppSection("test", "Test", tables = null, processes = null, reports = null).hasContent()) @@ -45,4 +45,4 @@ class QAppSectionTest assertTrue(QAppSection("test", "Test", reports = listOf("myReport")).hasContent()) assertTrue(QAppSection("test", "Test", tables = listOf("myTable"), processes = listOf("myProcess"), reports = listOf("myReport")).hasContent()) } -} \ No newline at end of file +} diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializerTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializerTest.kt index d75dcb5..9119ef1 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializerTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializerTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,24 +25,23 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata.authentication import kotlinx.serialization.json.Json import org.junit.Test -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class AuthenticationSerializerTest -{ - /*************************************************************************** + *******************************************************************************/ +class AuthenticationSerializerTest { + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testDecodeAuth0() - { + fun testDecodeAuth0() { val json = """ { "name":"auth0", "type":"AUTH_0", - "values": + "values": { "baseUrl":"https://auth.coldtrack-dev.com/", "clientId":"Q7hjPeDrfq11EgZyFxpoXQRTPhTn3KzN", @@ -55,4 +55,4 @@ class AuthenticationSerializerTest assertTrue(parsedItem is Auth0AuthenticationMetaData) assertEquals("Q7hjPeDrfq11EgZyFxpoXQRTPhTn3KzN", (parsedItem as Auth0AuthenticationMetaData).values.clientId) } -} \ No newline at end of file +} diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/widgets/WidgetBlockTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/widgets/WidgetBlockTest.kt index 379acb4..9f02b60 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/widgets/WidgetBlockTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/widgets/WidgetBlockTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,19 +24,19 @@ package com.kingsrook.qqq.frontend.android.core.model.widgets import org.junit.Test -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class WidgetBlockTest -{ - /*************************************************************************** + *******************************************************************************/ +class WidgetBlockTest { + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testFromMap() - { + fun testFromMap() { val widgetBlock = WidgetBlock.fromMap( mapOf( "blockType" to "TEXT", @@ -49,8 +50,7 @@ class WidgetBlockTest ) assertNotNull(widgetBlock) - if(widgetBlock != null) - { + if (widgetBlock != null) { assertEquals("", widgetBlock.blockId) assertEquals("TEXT", widgetBlock.blockType) assertNull(widgetBlock.conditional) @@ -60,4 +60,29 @@ class WidgetBlockTest assertEquals(emptyList(), widgetBlock.subBlocks) } } -} \ No newline at end of file + + /******************************************************************************* + ** Widget edits must not mutate immutable or shared decoded server metadata. + *******************************************************************************/ + @Test + fun inputMapsBecomeOwnedMutableWidgetState() { + val styles = mapOf("foo" to "bar") + val values = mapOf("text" to "original") + val block = requireNotNull(WidgetBlock.fromMap(mapOf("styles" to styles, "values" to values))) + block.styles["foo"] = "changed" + block.values["text"] = "edited" + assertEquals("bar", styles["foo"]) + assertEquals("original", values["text"]) + assertEquals("changed", block.styles["foo"]) + assertEquals("edited", block.values["text"]) + } + + + /******************************************************************************* + ** JSON property names are strings; malformed legacy maps must fail explicitly. + *******************************************************************************/ + @Test(expected = IllegalArgumentException::class) + fun nonStringPropertyNamesAreRejected() { + WidgetBlock.fromMap(mapOf("values" to mapOf(42 to "invalid"))) + } +} diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/utils/StringExtensionsTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/utils/StringExtensionsTest.kt index 73e9a86..6b42842 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/utils/StringExtensionsTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/utils/StringExtensionsTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,17 +24,15 @@ package com.kingsrook.qqq.frontend.android.core.utils import org.junit.Assert.assertEquals import org.junit.Test -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class StringExtensionsTest -{ - /*************************************************************************** + *******************************************************************************/ +class StringExtensionsTest { + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testFirstN() - { + fun testFirstN() { assertEquals("", "".firstN(0)); assertEquals("", "".firstN(1)); assertEquals("", "".firstN(100)); @@ -46,4 +45,4 @@ class StringExtensionsTest assertEquals("A", "ABC".firstN(1)); assertEquals("ABC", "ABC".firstN(100)); } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/build.gradle.kts b/qqq/android/mobileapp/build.gradle.kts index 5b75a49..d64a319 100644 --- a/qqq/android/mobileapp/build.gradle.kts +++ b/qqq/android/mobileapp/build.gradle.kts @@ -1,3 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) diff --git a/qqq/android/mobileapp/src/androidTest/java/com/kingsrook/qqq/frontend/android/mobileapp/ExampleInstrumentedTest.kt b/qqq/android/mobileapp/src/androidTest/java/com/kingsrook/qqq/frontend/android/mobileapp/ExampleInstrumentedTest.kt index 4295385..58edf25 100644 --- a/qqq/android/mobileapp/src/androidTest/java/com/kingsrook/qqq/frontend/android/mobileapp/ExampleInstrumentedTest.kt +++ b/qqq/android/mobileapp/src/androidTest/java/com/kingsrook/qqq/frontend/android/mobileapp/ExampleInstrumentedTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -26,21 +27,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* +import org.junit.Assert.assertEquals -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ +/******************************************************************************* + ** Instrumented test, which will execute on an Android device. + ** See [testing documentation](http://d.android.com/tools/testing). + *******************************************************************************/ @RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest -{ +class ExampleInstrumentedTest { + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun useAppContext() - { + fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.kingsrook.qqq.frontend.android.mobileapp.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileAppMain.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileAppMain.kt index 73019ae..773ef4d 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileAppMain.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileAppMain.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -75,43 +76,38 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.TopLevelAppState import kotlinx.coroutines.delay -enum class SplashState -{ +/******************************************************************************* + ** Keep the optional splash transition separate from authentication and business-navigation state. + *******************************************************************************/ +enum class SplashState { INITIAL, ACTIVE, DONE } -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter? = null, showNetworkDialog: Boolean = true) -{ - val splashState = remember { mutableStateOf(if(splashImage == null || qViewModel.didRunSplashAnimation) SplashState.DONE else SplashState.INITIAL) } +fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter? = null, showNetworkDialog: Boolean = true) { + val splashState = remember { mutableStateOf(if (splashImage == null || qViewModel.didRunSplashAnimation) SplashState.DONE else SplashState.INITIAL) } val splashFadeOutMillis = remember { mutableIntStateOf(1500) } val splashDurationMillis = 3000L val splashSlideInMillis = 2500 LaunchedEffect(splashState.value) { - when (splashState.value) - { - SplashState.INITIAL -> - { - if(!qViewModel.didRunSplashAnimation) - { + when (splashState.value) { + SplashState.INITIAL -> { + if (!qViewModel.didRunSplashAnimation) { qViewModel.didRunSplashAnimation = true; splashState.value = SplashState.ACTIVE } } - SplashState.ACTIVE -> - { + SplashState.ACTIVE -> { delay(splashDurationMillis) splashState.value = SplashState.DONE } - SplashState.DONE -> - { + SplashState.DONE -> { ////////// // noop // ////////// @@ -119,8 +115,7 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter? } } - if(splashImage != null) - { + if (splashImage != null) { AnimatedVisibility( splashState.value == SplashState.ACTIVE, enter = slideInVertically(animationSpec = tween(splashSlideInMillis, easing = FastOutSlowInEasing), initialOffsetY = { it }), @@ -152,22 +147,25 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter? val parentInputAllowed = LocalProcessInputAllowed.current val openDiagnostics = LocalOpenDiagnostics.current val businessReady = LocalBusinessNavigationEnabled.current && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested - fun drawerActive(): Boolean - { + + /******************************************************************************* + ** Block process input during partial drawer gestures and animations, not only when the drawer is fully open. + *******************************************************************************/ + fun drawerActive(): Boolean { val drawer = topLevelAppState.navDrawerState return topLevelAppState.drawerRequested || drawer.isOpen || drawer.targetValue == DrawerValue.Open || drawer.isAnimationRunning || // Material's closed anchor is minus the measured width, including partial swipes. (drawerWidth.intValue > 0 && drawer.currentOffset > -drawerWidth.intValue.toFloat()) } + val navigateBack: () -> Unit = { - if(drawerActive()) topLevelAppState.closeNavDrawer() else qViewModel.qNavigator.popStack() + if (drawerActive()) topLevelAppState.closeNavDrawer() else qViewModel.qNavigator.popStack() } LaunchedEffect(qViewModel.isAuthenticated, qViewModel.isLogoutRequested, businessReady) { - if(!businessReady) topLevelAppState.closeNavDrawer() - if(qViewModel.isLogoutRequested) - { + if (!businessReady) topLevelAppState.closeNavDrawer() + if (qViewModel.isLogoutRequested) { settingsOpen.value = false diagnosticsOpen.value = false } @@ -179,21 +177,23 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter? exit = ExitTransition.None ) { - ModalNavigationDrawer(drawerState = topLevelAppState.navDrawerState, + ModalNavigationDrawer( + drawerState = topLevelAppState.navDrawerState, drawerContent = { ModalDrawerSheet(Modifier.onSizeChanged { drawerWidth.intValue = it.width }) { Column(Modifier.fillMaxSize()) { Text(title, Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium) Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - if(businessReady) - { - NavigationDrawerItem(modifier = Modifier.testTag("drawer.home"), label = { Text("All apps") }, + if (businessReady) { + NavigationDrawerItem( + modifier = Modifier.testTag("drawer.home"), label = { Text("All apps") }, selected = qViewModel.qNavigator.atHome, onClick = { topLevelAppState.closeNavDrawer(); qViewModel.qNavigator.goHome() }) val instance = (qViewModel.qInstanceLoadState as? LoadState.Success)?.value instance?.appTree?.filter { it.type == QAppNodeType.APP }?.forEach { app -> - NavigationDrawerItem(modifier = Modifier.testTag("drawer.app:${app.name}"), label = { Text(app.label) }, + NavigationDrawerItem( + modifier = Modifier.testTag("drawer.app:${app.name}"), label = { Text(app.label) }, selected = qViewModel.qNavigator.currentAppName == app.name, onClick = { topLevelAppState.closeNavDrawer(); qViewModel.qNavigator.navigateToApp(app.name, app.label) }) @@ -207,53 +207,54 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter? }) NavigationDrawerItem(modifier = Modifier.testTag("drawer.diagnostics"), label = { Text("Diagnostics") }, selected = false, onClick = { topLevelAppState.closeNavDrawer() - if(openDiagnostics != null) openDiagnostics() else diagnosticsOpen.value = true + if (openDiagnostics != null) openDiagnostics() else diagnosticsOpen.value = true }) } } } }) { - CompositionLocalProvider(LocalNavigationBackAction provides navigateBack) { - Scaffold( - Modifier.fillMaxSize(), - topBar = { - QTopAppBar( - qViewModel, - navMenuCallback = topLevelAppState::openNavDrawer, - defaultTitle = title, - qNavigator = qViewModel.qNavigator, - showNetworkDialog = showNetworkDialog, - accountOpenState = accountOpen - ) - } - ) { innerPadding -> - Column( - Modifier - .padding(innerPadding) - .fillMaxSize() - ) - { - CompositionLocalProvider(LocalProcessInputAllowed provides { - parentInputAllowed() && businessReady && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested && - !drawerActive() && drawerWidth.intValue > 0 && topLevelAppState.navDrawerState.currentOffset.isFinite() && - !settingsOpen.value && !diagnosticsOpen.value && !accountOpen.value && - qViewModel.qNavigator.leaveRequest == null - }) { - QAuthenticationWrapper( - qViewModel, + CompositionLocalProvider(LocalNavigationBackAction provides navigateBack) { + Scaffold( + Modifier.fillMaxSize(), + topBar = { + QTopAppBar( + qViewModel, + navMenuCallback = topLevelAppState::openNavDrawer, + defaultTitle = title, + qNavigator = qViewModel.qNavigator, + showNetworkDialog = showNetworkDialog, + accountOpenState = accountOpen + ) + } + ) { innerPadding -> + Column( Modifier - .fillMaxSize(), - qNavigator = qViewModel.qNavigator, + .padding(innerPadding) + .fillMaxSize() ) + { + CompositionLocalProvider(LocalProcessInputAllowed provides { + parentInputAllowed() && businessReady && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested && + !drawerActive() && drawerWidth.intValue > 0 && topLevelAppState.navDrawerState.currentOffset.isFinite() && + !settingsOpen.value && !diagnosticsOpen.value && !accountOpen.value && + qViewModel.qNavigator.leaveRequest == null + }) { + QAuthenticationWrapper( + qViewModel, + Modifier + .fillMaxSize(), + qNavigator = qViewModel.qNavigator, + ) + } + } } } } - } - } // Authenticated content can register later; both handlers use the same live priority. BackHandler(enabled = drawerActive(), onBack = navigateBack) - if(settingsOpen.value) MenuDialog(qViewModel, title, settingsOpen) - if(diagnosticsOpen.value) AlertDialog(onDismissRequest = { diagnosticsOpen.value = false }, + if (settingsOpen.value) MenuDialog(qViewModel, title, settingsOpen) + if (diagnosticsOpen.value) AlertDialog( + onDismissRequest = { diagnosticsOpen.value = false }, title = { Text("Diagnostics") }, text = { Text("Server: ${qViewModel.environment.baseUrl}\nApplication version: ${QViewModel.applicationVersion}") }, confirmButton = { TextButton(onClick = { diagnosticsOpen.value = false }) { Text("Close") } }) LeaveProcessDialog(qViewModel) diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileApplication.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileApplication.kt index a11ea8f..9396411 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileApplication.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/QMobileApplication.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,19 +25,15 @@ import android.app.Application import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer import com.kingsrook.qqq.frontend.android.mobileapp.container.QDefaultAppContainer -/*************************************************************************** - ** - ***************************************************************************/ -class QMobileApplication : Application() -{ +/******************************************************************************* + *******************************************************************************/ +class QMobileApplication : Application() { lateinit var container: QAppContainer - /*************************************************************************** - ** - ***************************************************************************/ - override fun onCreate() - { + /******************************************************************************* + *******************************************************************************/ + override fun onCreate() { super.onCreate() container = QDefaultAppContainer() } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/container/QAppContainer.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/container/QAppContainer.kt index 09b7ff1..1de22e5 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/container/QAppContainer.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/container/QAppContainer.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,15 +24,15 @@ package com.kingsrook.qqq.frontend.android.mobileapp.container import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository import com.kingsrook.qqq.frontend.android.core.model.Environment -/*************************************************************************** - ** - ***************************************************************************/ -interface QAppContainer -{ +/******************************************************************************* + *******************************************************************************/ +interface QAppContainer { val qqqRepository: QQQRepository - companion object - { + /******************************************************************************* + ** Share configured application environments explicitly rather than deriving a server from UI state. + *******************************************************************************/ + companion object { lateinit var availableEnvironments: List } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/container/QDefaultAppContainer.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/container/QDefaultAppContainer.kt index afd848e..3774a0c 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/container/QDefaultAppContainer.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/container/QDefaultAppContainer.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,14 +25,12 @@ import com.kingsrook.qqq.frontend.android.core.controllers.NetworkQQQRepository import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer.Companion.availableEnvironments -/*************************************************************************** - ** - ***************************************************************************/ -class QDefaultAppContainer : QAppContainer -{ +/******************************************************************************* + *******************************************************************************/ +class QDefaultAppContainer : QAppContainer { override val qqqRepository: QQQRepository by lazy() { NetworkQQQRepository(availableEnvironments[0].baseUrl) } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/data/MockPreferencesDataStore.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/data/MockPreferencesDataStore.kt index 9bdf36a..c16f55c 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/data/MockPreferencesDataStore.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/data/MockPreferencesDataStore.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -26,24 +27,19 @@ import androidx.datastore.preferences.core.preferencesOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf -/*************************************************************************** - ** - ***************************************************************************/ -class MockPreferencesDataStore : DataStore -{ +/******************************************************************************* + *******************************************************************************/ +class MockPreferencesDataStore : DataStore { var preferences: Preferences = preferencesOf().toMutablePreferences() - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ override val data: Flow get() = flowOf(preferences) - /*************************************************************************** - ** - ***************************************************************************/ - override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences - { + /******************************************************************************* + *******************************************************************************/ + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { preferences = transform.invoke(preferences) return preferences } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/data/SettingsRepository.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/data/SettingsRepository.kt index 531b583..8e8bd2b 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/data/SettingsRepository.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/data/SettingsRepository.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -35,72 +36,57 @@ val SESSION_USER_FULL_NAME_KEY = stringPreferencesKey("session_user_full_name") val ENVIRONMENT_JSON_KEY = stringPreferencesKey("environment_json") -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ class SettingsRepository( private val dataStore: DataStore, -) -{ +) { - /*************************************************************************** - ** - ***************************************************************************/ - suspend fun getSessionUUID(): String - { + /******************************************************************************* + *******************************************************************************/ + suspend fun getSessionUUID(): String { return (dataStore.data.first()[SESSION_UUID_KEY] ?: "") } - /*************************************************************************** - ** - ***************************************************************************/ - suspend fun setSessionUUID(sessionUUID: String) - { + /******************************************************************************* + *******************************************************************************/ + suspend fun setSessionUUID(sessionUUID: String) { dataStore.edit { preferences -> preferences[SESSION_UUID_KEY] = sessionUUID } } - /*************************************************************************** - ** - ***************************************************************************/ - suspend fun getSessionUserFullName(): String - { + /******************************************************************************* + *******************************************************************************/ + suspend fun getSessionUserFullName(): String { return (dataStore.data.first()[SESSION_USER_FULL_NAME_KEY] ?: "") } - /*************************************************************************** - ** - ***************************************************************************/ - suspend fun setSessionUserFullName(value: String) - { + /******************************************************************************* + *******************************************************************************/ + suspend fun setSessionUserFullName(value: String) { dataStore.edit { preferences -> preferences[SESSION_USER_FULL_NAME_KEY] = value } } - /*************************************************************************** - ** - ***************************************************************************/ - suspend fun getEnvironment(): Environment? - { + /******************************************************************************* + *******************************************************************************/ + suspend fun getEnvironment(): Environment? { val environmentJsonString = (dataStore.data.first()[ENVIRONMENT_JSON_KEY] ?: "") - if(environmentJsonString == "") - { + if (environmentJsonString == "") { return null } return Json.decodeFromString(environmentJsonString) } - /*************************************************************************** - ** - ***************************************************************************/ - suspend fun setEnvironment(value: Environment) - { + /******************************************************************************* + *******************************************************************************/ + suspend fun setEnvironment(value: Environment) { dataStore.edit { preferences -> preferences[ENVIRONMENT_JSON_KEY] = Json.encodeToString(value) } } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/HostExtensions.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/HostExtensions.kt index 9cb2907..87f2428 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/HostExtensions.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/HostExtensions.kt @@ -8,20 +8,28 @@ import androidx.compose.ui.Modifier import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/** Applications can enforce device enrollment before exposing native QQQ content. */ +/******************************************************************************* + ** Applications can enforce device enrollment before exposing native QQQ content. + *******************************************************************************/ val LocalAuthenticatedContent = staticCompositionLocalOf<@Composable (QViewModel, @Composable () -> Unit) -> Unit>() { { _, content -> content() } } -/** Unknown widgets can be implemented by a host without forking the standard renderer. */ +/******************************************************************************* + ** Unknown widgets can be implemented by a host without forking the standard renderer. + *******************************************************************************/ val LocalCustomWidgetRenderer = staticCompositionLocalOf<@Composable (WidgetBlockParameters, Modifier, Boolean) -> Boolean>() { { _, _, _ -> false } } -/** Live surface availability for asynchronous input; nested hosts must preserve the parent's gate. */ +/******************************************************************************* + ** Live surface availability for asynchronous input; nested hosts must preserve the parent's gate. + *******************************************************************************/ val LocalProcessInputAllowed = compositionLocalOf<() -> Boolean> { { true } } -/** Host surface priority shared by toolbar and system Back, independent of callback registration order. */ +/******************************************************************************* + ** Host surface priority shared by toolbar and system Back, independent of callback registration order. + *******************************************************************************/ internal val LocalNavigationBackAction = compositionLocalOf<(() -> Unit)?> { null } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt index 9dd0c67..fe55424 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -37,11 +38,9 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/*************************************************************************** - ** - ***************************************************************************/ -class QNavigator(initialController: NavHostController?, val qViewModel: QViewModel) : ViewModel() -{ +/******************************************************************************* + *******************************************************************************/ +class QNavigator(initialController: NavHostController?, val qViewModel: QViewModel) : ViewModel() { var navDepth: Int by mutableStateOf(0) private set @@ -56,108 +55,128 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod var currentAppName: String? by mutableStateOf(null) private set - /** A destination is applied only after the current process confirms cancellation. */ + /******************************************************************************* + ** A destination is applied only after the current process confirms cancellation. + *******************************************************************************/ class LeaveRequest(val title: String, val confirmLabel: String, val action: () -> Unit) + var leaveRequest: LeaveRequest? by mutableStateOf(null) private set - fun dismissLeave() { if(qViewModel.activeProcessViewModel?.cancelling != true) leaveRequest = null } + /******************************************************************************* + ** Dismiss confirmation only while cancellation is idle, preserving the in-flight process ownership decision. + *******************************************************************************/ + fun dismissLeave() { + if (qViewModel.activeProcessViewModel?.cancelling != true) leaveRequest = null + } - fun requestLeave(title: String, confirmLabel: String = "Leave process", alwaysConfirm: Boolean = false, action: () -> Unit) - { - if(leaveRequest != null || qViewModel.logoutInProgress) return + /******************************************************************************* + ** Apply the same guarded process-exit policy before every destination or sign-out action. + *******************************************************************************/ + fun requestLeave(title: String, confirmLabel: String = "Leave process", alwaysConfirm: Boolean = false, action: () -> Unit) { + if (leaveRequest != null || qViewModel.logoutInProgress) return val process = qViewModel.activeProcessViewModel - if(alwaysConfirm || process != null && !process.getNoMoreSteps()) + if (alwaysConfirm || process != null && !process.getNoMoreSteps()) leaveRequest = LeaveRequest(title, confirmLabel, action) else finishLeave(action) } - fun confirmLeave() - { + /******************************************************************************* + ** Honor the pending destination only after current client work permits process cancellation. + *******************************************************************************/ + fun confirmLeave() { val request = leaveRequest ?: return val process = qViewModel.activeProcessViewModel - if(process?.navigationBlockReason() != null || process?.cancelling == true) return + if (process?.navigationBlockReason() != null || process?.cancelling == true) return finishLeave(request.action) } - /** A second explicit choice after failed cancellation; this does not claim the server stopped. */ - fun leaveAnyway() - { + /******************************************************************************* + ** A second explicit choice after failed cancellation; this does not claim the server stopped. + *******************************************************************************/ + fun leaveAnyway() { val request = leaveRequest ?: return val process = qViewModel.activeProcessViewModel ?: return - if(!process.cancellationFailed || process.cancelling || process.navigationBlockReason() != null) return + if (!process.cancellationFailed || process.cancelling || process.navigationBlockReason() != null) return process.reset() qViewModel.activeProcessViewModel = null leaveRequest = null request.action() } - private fun finishLeave(action: () -> Unit) - { + /******************************************************************************* + ** Wait for process cancellation before applying the destination and dropping its active ViewModel. + *******************************************************************************/ + private fun finishLeave(action: () -> Unit) { val process = qViewModel.activeProcessViewModel - if(process != null && !process.getNoMoreSteps()) - { + if (process != null && !process.getNoMoreSteps()) { process.closeProcessCallback = { qViewModel.activeProcessViewModel = null leaveRequest = null action() } process.cancel() - } - else - { + } else { qViewModel.activeProcessViewModel = null leaveRequest = null action() } } + /******************************************************************************* + ** 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() } - /** The same guarded leave/cancel policy applies to explicitly enabled provider sign-out. */ + /******************************************************************************* + ** The same guarded leave/cancel policy applies to explicitly enabled provider sign-out. + *******************************************************************************/ fun requestCompanySignOut(binding: com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding) = requestLeave("Sign out of company?", "Sign out of company", alwaysConfirm = true) { qViewModel.requestCompanyLogout(binding) } private val destinationListener = NavController.OnDestinationChangedListener { controller, _, _ -> reconcile(controller) } var navController: NavHostController? = null - set(value) - { - if(field === value) return + set(value) { + if (field === value) return field?.removeOnDestinationChangedListener(destinationListener) field = value value?.addOnDestinationChangedListener(destinationListener) - if(value?.currentBackStackEntry == null) clearDestination() + if (value?.currentBackStackEntry == null) clearDestination() } - init { navController = initialController } + init { + navController = initialController + } - /** NavController is authoritative, including Android Back and restored controller state. */ - private fun reconcile(controller: NavController) - { + /******************************************************************************* + ** NavController is authoritative, including Android Back and restored controller state. + *******************************************************************************/ + private fun reconcile(controller: NavController) { val entry = controller.currentBackStackEntry ?: return clearDestination() val instance = (qViewModel.qInstanceLoadState as? LoadState.Success)?.value - val processName = if(entry.destination.hasRoute()) entry.toRoute().name else null - val appName = if(entry.destination.hasRoute()) entry.toRoute().name else - if(entry.destination.hasRoute()) entry.toRoute().appName else null + val processName = if (entry.destination.hasRoute()) entry.toRoute().name else null + val appName = if (entry.destination.hasRoute()) entry.toRoute().name else + if (entry.destination.hasRoute()) entry.toRoute().appName else null atHome = entry.destination.hasRoute() - navDepth = if(atHome) 0 else 1 - currentAppName = if(atHome) null else appName + navDepth = if (atHome) 0 else 1 + currentAppName = if (atHome) null else appName val label = entry.savedStateHandle.get("qqqTitle") ?: processName?.let { instance?.processes?.get(it)?.label ?: it } - ?: appName?.let { instance?.apps?.get(it)?.label ?: it } - titleStack = if(atHome) emptyList() else listOf(label) + ?: appName?.let { instance?.apps?.get(it)?.label ?: it } + titleStack = if (atHome) emptyList() else listOf(label) navigationRevision++ qViewModel.topBarStatusText = null qViewModel.activeProcessViewModel?.let { process -> - if(process.processName != processName) - { + if (process.processName != processName) { process.reset() qViewModel.activeProcessViewModel = null } } } - private fun clearDestination() - { + /******************************************************************************* + ** Reset the derived navigation summary when the controller has no authoritative destination. + *******************************************************************************/ + private fun clearDestination() { atHome = true navDepth = 0 titleStack = emptyList() @@ -165,26 +184,23 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod navigationRevision++ } - /*************************************************************************** - ** - ***************************************************************************/ - fun navigateToApp(name: String, label: String) - { - if(currentAppName == name && qViewModel.activeProcessViewModel == null) return + /******************************************************************************* + *******************************************************************************/ + fun navigateToApp(name: String, label: String) { + if (currentAppName == name && qViewModel.activeProcessViewModel == null) return requestLeave("Switch app?") { homeNow() navigateToRoute(AppRoute(name), label) } } - /*************************************************************************** + /******************************************************************************* ** As part of preserving the process view-model in case of activity restarts ** (e.g., when screen orientation changes) - manage the lifecycle of the ** active process view model in here - activating a new one when navigating. - ***************************************************************************/ - fun navigateToProcess(name: String, label: String, processViewModel: ProcessViewModel) - { - if(qViewModel.activeProcessViewModel != null || leaveRequest != null) return + *******************************************************************************/ + fun navigateToProcess(name: String, label: String, processViewModel: ProcessViewModel) { + if (qViewModel.activeProcessViewModel != null || leaveRequest != null) return processViewModel.reset() processViewModel.qqqRepository = qViewModel.qqqRepository processViewModel.qInstance = (qViewModel.qInstanceLoadState as LoadState.Success).value @@ -205,11 +221,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod navigateToRoute(ProcessRoute(name, currentAppName), label) } - /*************************************************************************** - ** - ***************************************************************************/ - private fun navigateToRoute(route: Any, label: String) - { + /******************************************************************************* + *******************************************************************************/ + private fun navigateToRoute(route: Any, label: String) { navController?.let { controller -> controller.navigate(route) controller.currentBackStackEntry?.savedStateHandle?.set("qqqTitle", label) @@ -217,23 +231,18 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod } } - /*************************************************************************** - ** - ***************************************************************************/ - fun popStack() - { - if(atHome) return + /******************************************************************************* + *******************************************************************************/ + fun popStack() { + if (atHome) return val title = titleStack.lastOrNull() ?: "process" requestLeave("Leave $title?") { navController?.popBackStack() } } - /*************************************************************************** - ** - ***************************************************************************/ - fun goHome(cancelActiveProcess: Boolean = true) - { - if(!cancelActiveProcess) - { + /******************************************************************************* + *******************************************************************************/ + fun goHome(cancelActiveProcess: Boolean = true) { + if (!cancelActiveProcess) { leaveRequest = null qViewModel.activeProcessViewModel?.reset() qViewModel.activeProcessViewModel = null @@ -243,11 +252,12 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod requestLeave("Return to Apps?") { homeNow() } } - private fun homeNow() - { + /******************************************************************************* + ** Navigate home through the existing controller while resetting derived destination state consistently. + *******************************************************************************/ + private fun homeNow() { navController?.let { controller -> - if(!controller.popBackStack(inclusive = false) && !atHome) - { + if (!controller.popBackStack(inclusive = false) && !atHome) { controller.navigate(HomeRoute) { popUpTo(controller.graph.id) { inclusive = true } } } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHome.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHome.kt index 3cc7814..810af9b 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHome.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHome.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -27,14 +28,13 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.QAppMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance import com.kingsrook.qqq.frontend.android.mobileapp.ui.QNavigator -/*************************************************************************** +/******************************************************************************* ** screen that renders an app (e.g., a QQQAppMetaData) - ***************************************************************************/ + *******************************************************************************/ @Composable -fun QAppHome(qInstance: QInstance, modifier: Modifier = Modifier, app: QAppMetaData? = null, qNavigator: QNavigator? = null) -{ +fun QAppHome(qInstance: QInstance, modifier: Modifier = Modifier, app: QAppMetaData? = null, qNavigator: QNavigator? = null) { /////////////////////////////////////////////////////////////////////// // idk, in case we do other layouts in the future, simple layer here // /////////////////////////////////////////////////////////////////////// QAppHomeGridLayout(qInstance, modifier, app, qNavigator) -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHomeGridLayout.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHomeGridLayout.kt index 39b2f8f..0d2fdf8 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHomeGridLayout.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/apps/QAppHomeGridLayout.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -65,13 +66,12 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.QNavigator import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.DynamicIcon import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel -/*************************************************************************** +/******************************************************************************* ** Standard screen for an app - shows icons in a grid. - ***************************************************************************/ + *******************************************************************************/ @OptIn(ExperimentalLayoutApi::class) // for FlowRow @Composable -fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app: QAppMetaData? = null, qNavigator: QNavigator? = null) -{ +fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app: QAppMetaData? = null, qNavigator: QNavigator? = null) { val childList = app?.let { app.children } ?: qInstance.appTree val widgetList = app?.let { app.widgets } ?: emptyList() @@ -85,7 +85,7 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app: val generalSize = 100 - LazyColumn(Modifier.fillMaxWidth().padding(top = 8.dp)) + LazyColumn(modifier.fillMaxWidth().padding(top = 8.dp)) { /////////////////////////////////////////////////////////////////////////////////////////////////// // at this point in time, we're doing the title in the topBar, so, redundant to be on-screen too // @@ -104,23 +104,21 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app: } */ - if(widgetList.isNotEmpty()) - { + if (widgetList.isNotEmpty()) { item { Text("This app includes home widgets unavailable in this client.", Modifier.padding(8.dp)) } } - if(childList.isEmpty() && app?.sections?.isEmpty() != false && widgetList.isEmpty()) - { + if (childList.isEmpty() && app?.sections?.isEmpty() != false && widgetList.isEmpty()) { item() { Text( - if(app == null) "No apps are available for this account." else "You do not have access to anything in this app.", + if (app == null) "No apps are available for this account." else "You do not have access to anything in this app.", fontSize = 16.sp, fontStyle = FontStyle.Italic, modifier = Modifier .padding(8.dp) .testTag("appHome.noAccess") ) - if(app == null) TextButton(onClick = { qNavigator?.qViewModel?.loadQInstance() }) { Text("Retry loading apps") } + if (app == null) TextButton(onClick = { qNavigator?.qViewModel?.loadQInstance() }) { Text("Retry loading apps") } } } @@ -131,21 +129,18 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app: /////////////////////////// // first show child-apps // /////////////////////////// - for(childApp in childApps) - { + for (childApp in childApps) { Tile(childApp.label, childApp.icon, generalSize, "appHome.rowForApp:" + childApp.name) { qNavigator?.navigateToApp(childApp.name, childApp.label) } } - for(child in otherChildren) - { + for (child in otherChildren) { val processViewModel: ProcessViewModel = viewModel(factory = ProcessViewModel.factory) Tile(child.label, child.icon, generalSize, "appHome.rowFor${child.type}:" + child.name) { - if(child.type == QAppNodeType.PROCESS) qNavigator?.navigateToProcess(child.name, child.label, processViewModel) - else - { + if (child.type == QAppNodeType.PROCESS) qNavigator?.navigateToProcess(child.name, child.label, processViewModel) + else { notYetImplementedDialogTitle.value = child.label notYetImplementedDialogBody.value = "This type of screen is unavailable in this client." isNotYetImplementedDialogOpen.value = true @@ -160,8 +155,7 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app: //////////////////////////////////////////////////// app?.sections?.forEach() { section -> - if(section.hasContent()) - { + if (section.hasContent()) { item() { FlowRow(Modifier.fillMaxWidth()) @@ -233,19 +227,16 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app: } } - if(isNotYetImplementedDialogOpen.value) - { + if (isNotYetImplementedDialogOpen.value) { NotYetImplementedDialog(notYetImplementedDialogTitle.value, notYetImplementedDialogBody.value, isNotYetImplementedDialogOpen) } } -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -private fun Tile(label: String, icon: QIcon?, generalSize: Int, testTag: String, onClick: () -> Unit) -{ +private fun Tile(label: String, icon: QIcon?, generalSize: Int, testTag: String, onClick: () -> Unit) { Column( modifier = Modifier .padding(8.dp) @@ -262,10 +253,12 @@ private fun Tile(label: String, icon: QIcon?, generalSize: Int, testTag: String, .align(Alignment.CenterHorizontally) ) { - Box(Modifier - .background(Color(0xFe, 0xFe, 0xFe), RoundedCornerShape(16.dp)) - .border(2.dp, Color(0xf0, 0xf0, 0xf0), RoundedCornerShape(16.dp)) - .padding(12.dp)) + Box( + Modifier + .background(Color(0xFe, 0xFe, 0xFe), RoundedCornerShape(16.dp)) + .border(2.dp, Color(0xf0, 0xf0, 0xf0), RoundedCornerShape(16.dp)) + .padding(12.dp) + ) { DynamicIcon(icon, R.drawable.apps, Modifier.fillMaxSize()) } @@ -284,13 +277,10 @@ private fun Tile(label: String, icon: QIcon?, generalSize: Int, testTag: String, } - -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun NotYetImplementedDialog(title: String, body: String, isOpen: MutableState) -{ +fun NotYetImplementedDialog(title: String, body: String, isOpen: MutableState) { AlertDialog( icon = { Icon(imageVector = Icons.Default.Info, contentDescription = "Info") }, title = { Text(title) }, diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/Auth0Browser.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/Auth0Browser.kt index 5bb7368..0f895fe 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/Auth0Browser.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/Auth0Browser.kt @@ -9,19 +9,35 @@ import com.auth0.android.callback.Callback import com.auth0.android.provider.WebAuthProvider import com.auth0.android.result.Credentials -/** Platform boundary: production always uses Auth0's external browser implementation. */ -interface Auth0Browser -{ +/******************************************************************************* + ** Platform boundary: production always uses Auth0's external browser implementation. + *******************************************************************************/ +interface Auth0Browser { + /******************************************************************************* + ** Launch the configured provider through the platform browser boundary and return its result to the owning attempt. + *******************************************************************************/ fun login(context: Context, account: Auth0, audience: String, scheme: String, callback: Callback) + + /******************************************************************************* + ** Request provider sign-out through the platform browser boundary without recreating a QQQ session. + *******************************************************************************/ fun logout(context: Context, account: Auth0, scheme: String, callback: Callback) } val LocalAuth0Browser = staticCompositionLocalOf { - object : Auth0Browser - { - override fun login(context: Context, account: Auth0, audience: String, scheme: String, callback: Callback) - { WebAuthProvider.login(account).withAudience(audience).withScheme(scheme).start(context, callback) } - override fun logout(context: Context, account: Auth0, scheme: String, callback: Callback) - { WebAuthProvider.logout(account).withScheme(scheme).start(context, callback) } + object : Auth0Browser { + /******************************************************************************* + ** Launch the configured provider through the platform browser boundary and return its result to the owning attempt. + *******************************************************************************/ + override fun login(context: Context, account: Auth0, audience: String, scheme: String, callback: Callback) { + WebAuthProvider.login(account).withAudience(audience).withScheme(scheme).start(context, callback) + } + + /******************************************************************************* + ** Request provider sign-out through the platform browser boundary without recreating a QQQ session. + *******************************************************************************/ + override fun logout(context: Context, account: Auth0, scheme: String, callback: Callback) { + WebAuthProvider.logout(account).withScheme(scheme).start(context, callback) + } } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LoginAttempt.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LoginAttempt.kt index 91aa0d4..f2e78b8 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LoginAttempt.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LoginAttempt.kt @@ -5,9 +5,14 @@ import java.net.URI import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.isValidExpectedNonce +/******************************************************************************* + ** Expose one native login lifecycle so recreation cannot launch competing browser exchanges. + *******************************************************************************/ enum class OAuth2LoginPhase { READY, PREPARING, BROWSER, EXCHANGING } -/** Identity of one server-advertised login configuration, never inferred from a provider brand. */ +/******************************************************************************* + ** Identity of one server-advertised login configuration, never inferred from a provider brand. + *******************************************************************************/ data class OAuth2LoginBinding( val server: String, val providerName: String, @@ -20,57 +25,86 @@ data class OAuth2LoginBinding( val nonceValidation: String = "" ) -/** Only an explicit compatible semantic grant enables sign-in; a bearer client is never a fallback. */ -fun oauth2LoginBinding(metadata: OAuth2AuthenticationMetaData, server: String, packageName: String): OAuth2LoginBinding? -{ +/******************************************************************************* + ** Only an explicit compatible semantic grant enables sign-in; a bearer client is never a fallback. + *******************************************************************************/ +fun oauth2LoginBinding(metadata: OAuth2AuthenticationMetaData, server: String, packageName: String): OAuth2LoginBinding? { val grant = metadata.values.nativeSessionLogin ?: return null val redirect = oauth2RedirectUri(packageName) - if(grant.flow != OAuth2AuthenticationMetaData.SESSION_FLOW || grant.codeChallengeMethod != "S256" || grant.prompt != "login" || + if (grant.flow != OAuth2AuthenticationMetaData.SESSION_FLOW || grant.codeChallengeMethod != "S256" || grant.prompt != "login" || grant.nonceValidation != "REQUIRED" || redirect !in grant.redirectUris || !isOAuth2HttpsUrl(server) || !isOAuth2HttpsUrl(grant.issuer) || !isOAuth2HttpsUrl(grant.discoveryUrl) || metadata.values.clientId.isBlank() || - "openid" !in metadata.values.scopes.split(' ')) return null - return OAuth2LoginBinding(server, metadata.name, grant.issuer, grant.discoveryUrl, metadata.values.clientId, - metadata.values.scopes, redirect, grant.prompt, grant.nonceValidation) + "openid" !in metadata.values.scopes.split(' ') + ) return null + return OAuth2LoginBinding( + server, metadata.name, grant.issuer, grant.discoveryUrl, metadata.values.clientId, + metadata.values.scopes, redirect, grant.prompt, grant.nonceValidation + ) } -/** One in-memory browser attempt. Activity recreation retains it; process death requires a new login. */ -class OAuth2LoginAttempt(private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }) -{ - private class Pending(val binding: OAuth2LoginBinding, val state: String, val nonce: String, - val verifier: String, val startedAt: Long) +/******************************************************************************* + ** One in-memory browser attempt. Activity recreation retains it; process death requires a new login. + *******************************************************************************/ +class OAuth2LoginAttempt(private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }) { + /******************************************************************************* + ** Keep the original state, nonce, verifier and provider binding only for the lifetime of one login attempt. + *******************************************************************************/ + private class Pending( + val binding: OAuth2LoginBinding, val state: String, val nonce: String, + val verifier: String, val startedAt: Long + ) + private var pending: Pending? = null - fun begin(binding: OAuth2LoginBinding, state: String, nonce: String, verifier: String) - { + /******************************************************************************* + ** Bind fresh state, nonce and PKCE verifier to the advertised configuration before opening the browser. + *******************************************************************************/ + fun begin(binding: OAuth2LoginBinding, state: String, nonce: String, verifier: String) { require(state.length >= 16 && isValidExpectedNonce(nonce) && binding.nonceValidation == "REQUIRED") require(Regex("[A-Za-z0-9._~-]{43,128}").matches(verifier)) check(pending == null) pending = Pending(binding, state, nonce, verifier, nowMillis()) } - /** Consume even rejected callbacks so a stale or failed attempt cannot later establish a session. */ - fun consume(binding: OAuth2LoginBinding, state: String?, requestState: String?, requestNonce: String?, - requestClientId: String, requestRedirectUri: String, requestPrompt: String?, code: String?): Exchange? - { + /******************************************************************************* + ** Consume even rejected callbacks so a stale or failed attempt cannot later establish a session. + *******************************************************************************/ + fun consume( + binding: OAuth2LoginBinding, state: String?, requestState: String?, requestNonce: String?, + requestClientId: String, requestRedirectUri: String, requestPrompt: String?, code: String? + ): Exchange? { val attempt = pending ?: return null pending = null val age = nowMillis() - attempt.startedAt - if(age !in 0..300_000 || binding != attempt.binding || state != attempt.state || requestState != attempt.state || + if (age !in 0..300_000 || binding != attempt.binding || state != attempt.state || requestState != attempt.state || requestNonce != attempt.nonce || requestClientId != binding.clientId || requestRedirectUri != binding.redirectUri || - requestPrompt != binding.prompt || code.isNullOrBlank()) return null + requestPrompt != binding.prompt || code.isNullOrBlank() + ) return null return Exchange(code, attempt.verifier, attempt.binding.redirectUri, attempt.nonce) } - fun cancel() { pending = null } + /******************************************************************************* + ** Drop the pending exchange material so an abandoned callback cannot later establish identity. + *******************************************************************************/ + fun cancel() { + pending = null + } - /** Not a data class: credentials must not appear in an automatically generated toString(). */ + /******************************************************************************* + ** Not a data class: credentials must not appear in an automatically generated toString(). + *******************************************************************************/ class Exchange(val code: String, val verifier: String, val redirectUri: String, val expectedNonce: String) } -/** Fixed package identity is the callback transport; the server must explicitly advertise this exact URI. */ +/******************************************************************************* + ** Fixed package identity is the callback transport; the server must explicitly advertise this exact URI. + *******************************************************************************/ fun oauth2RedirectUri(packageName: String): String = "$packageName.oauth://callback/oauth2redirect" +/******************************************************************************* + ** Match only this application's exact callback authority/path and reject opaque or fragment-bearing results. + *******************************************************************************/ fun isOAuth2Callback(uri: String, packageName: String): Boolean = runCatching { val actual = URI(uri) val expected = URI(oauth2RedirectUri(packageName)) @@ -78,7 +112,9 @@ fun isOAuth2Callback(uri: String, packageName: String): Boolean = runCatching { actual.rawFragment == null && !actual.isOpaque }.getOrDefault(false) -/** Reject credentials, query strings and fragments in configured issuer/server URLs. */ +/******************************************************************************* + ** Reject credentials, query strings and fragments in configured issuer/server URLs. + *******************************************************************************/ fun isOAuth2HttpsUrl(value: String): Boolean = runCatching { val uri = URI(value) uri.scheme == "https" && !uri.host.isNullOrBlank() && uri.rawUserInfo == null && uri.rawQuery == null && diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LogoutAttempt.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LogoutAttempt.kt index 9cb75c1..9cb5d95 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LogoutAttempt.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LogoutAttempt.kt @@ -4,45 +4,65 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.NativeLogoutData import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData +/******************************************************************************* + ** Track optional company sign-out independently from ordinary local session invalidation. + *******************************************************************************/ enum class OAuth2LogoutPhase { IDLE, PREPARING, BROWSER, FINISHED } -/** Contains public configuration only; login registration alone never authorizes a logout callback. */ +/******************************************************************************* + ** Contains public configuration only; login registration alone never authorizes a logout callback. + *******************************************************************************/ data class OAuth2LogoutBinding(val login: OAuth2LoginBinding, val postLogoutRedirectUri: String) -/** An optional explicit capability, independent of the host's choice of when to invoke it. */ -fun oauth2LogoutBinding(metadata: OAuth2AuthenticationMetaData, server: String, packageName: String): OAuth2LogoutBinding? -{ +/******************************************************************************* + ** An optional explicit capability, independent of the host's choice of when to invoke it. + *******************************************************************************/ +fun oauth2LogoutBinding(metadata: OAuth2AuthenticationMetaData, server: String, packageName: String): OAuth2LogoutBinding? { val login = oauth2LoginBinding(metadata, server, packageName) ?: return null val logout = metadata.values.nativeSessionLogout ?: return null - if(logout.flow != OAuth2AuthenticationMetaData.LOGOUT_FLOW || login.redirectUri !in logout.postLogoutRedirectUris) return null + if (logout.flow != OAuth2AuthenticationMetaData.LOGOUT_FLOW || login.redirectUri !in logout.postLogoutRedirectUris) return null return OAuth2LogoutBinding(login, login.redirectUri) } -/** Compare the server response to independent discovery and the originally selected server/provider. */ -fun validOAuth2LogoutData(data: NativeLogoutData, binding: OAuth2LogoutBinding, - discoveryIssuer: String?, discoveryEndSessionEndpoint: String?): Boolean = +/******************************************************************************* + ** Compare the server response to independent discovery and the originally selected server/provider. + *******************************************************************************/ +fun validOAuth2LogoutData( + data: NativeLogoutData, binding: OAuth2LogoutBinding, + discoveryIssuer: String?, discoveryEndSessionEndpoint: String? +): Boolean = data.issuer == binding.login.issuer && discoveryIssuer == binding.login.issuer && data.endSessionEndpoint == discoveryEndSessionEndpoint && isOAuth2HttpsUrl(data.endSessionEndpoint) && data.postLogoutRedirectUri == binding.postLogoutRedirectUri && data.idTokenHint.isNotBlank() && data.idTokenHint.length <= 32768 && data.idTokenHint.none { it.isWhitespace() || it.isISOControl() } -/** Single-use state-only completion. No token or provider hint is retained here or in saved state. */ -class OAuth2LogoutAttempt(private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }) -{ +/******************************************************************************* + ** Single-use state-only completion. No token or provider hint is retained here or in saved state. + *******************************************************************************/ +class OAuth2LogoutAttempt(private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }) { + /******************************************************************************* + ** Retain only public binding and single-use state for the current end-session response. + *******************************************************************************/ private class Pending(val binding: OAuth2LogoutBinding, val endpoint: String, val state: String, val startedAt: Long) + private var pending: Pending? = null - fun begin(binding: OAuth2LogoutBinding, endpoint: String, state: String) - { + /******************************************************************************* + ** Bind a fresh state to the chosen HTTPS end-session endpoint before starting company sign-out. + *******************************************************************************/ + fun begin(binding: OAuth2LogoutBinding, endpoint: String, state: String) { require(state.length >= 16 && isOAuth2HttpsUrl(endpoint)) check(pending == null) pending = Pending(binding, endpoint, state, nowMillis()) } - /** A rejected response consumes the attempt too; a duplicate can never change the outcome. */ - fun consume(binding: OAuth2LogoutBinding, state: String?, requestState: String?, - requestEndpoint: String?, requestRedirect: String?): Boolean - { + /******************************************************************************* + ** A rejected response consumes the attempt too; a duplicate can never change the outcome. + *******************************************************************************/ + fun consume( + binding: OAuth2LogoutBinding, state: String?, requestState: String?, + requestEndpoint: String?, requestRedirect: String? + ): Boolean { val current = pending ?: return false pending = null return nowMillis() - current.startedAt in 0..300_000 && binding == current.binding && @@ -50,5 +70,10 @@ class OAuth2LogoutAttempt(private val nowMillis: () -> Long = { System.nanoTime( requestRedirect == current.binding.postLogoutRedirectUri } - fun cancel() { pending = null } + /******************************************************************************* + ** Consume pending company sign-out state without claiming the provider completed logout. + *******************************************************************************/ + fun cancel() { + pending = null + } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2RedirectActivity.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2RedirectActivity.kt index 0a58439..d44b96a 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2RedirectActivity.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2RedirectActivity.kt @@ -6,18 +6,21 @@ import android.content.Intent import android.os.Bundle import net.openid.appauth.AuthorizationManagementActivity -/** Validate explicit intents too: Android intent filters alone do not protect an exported callback. */ -class OAuth2RedirectActivity : Activity() -{ - override fun onCreate(savedInstanceState: Bundle?) - { +/******************************************************************************* + ** Validate explicit intents too: Android intent filters alone do not protect an exported callback. + *******************************************************************************/ +class OAuth2RedirectActivity : Activity() { + /******************************************************************************* + ** Deliver only the expected native callback transport to AppAuth, then finish this transient activity. + *******************************************************************************/ + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val callback = intent.data - if(intent.action == Intent.ACTION_VIEW && callback != null && isOAuth2Callback(callback.toString(), packageName) && + if (intent.action == Intent.ACTION_VIEW && callback != null && isOAuth2Callback(callback.toString(), packageName) && listOf("state", "code", "error").all { callback.getQueryParameters(it).size <= 1 } && callback.getQueryParameter("state")?.isNotBlank() == true && - (callback.getQueryParameter("code") == null || callback.getQueryParameter("error") == null)) - { + (callback.getQueryParameter("code") == null || callback.getQueryParameter("error") == null) + ) { startActivity(AuthorizationManagementActivity.createResponseHandlingIntent(this, callback)) } finish() diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Login.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Login.kt index 5771fac..756ea6e 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Login.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Login.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -43,9 +44,11 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import kotlinx.coroutines.launch +/******************************************************************************* + ** Bind the provider browser result to the current server/login attempt before exchanging it for QQQ identity. + *******************************************************************************/ @Composable -fun QAuth0Login(qViewModel: QViewModel, modifier: Modifier = Modifier) -{ +fun QAuth0Login(qViewModel: QViewModel, modifier: Modifier = Modifier) { val metadata = (qViewModel.authenticationMetaDataLoadState as? LoadState.Success)?.value as? Auth0AuthenticationMetaData ?: return val context = LocalContext.current val launcher = LocalAuth0Browser.current @@ -56,42 +59,44 @@ fun QAuth0Login(qViewModel: QViewModel, modifier: Modifier = Modifier) DisposableEffect(qViewModel, qViewModel.environment.baseUrl, metadata, retry) { val attempt = qViewModel.prepareAuthentication() - if(attempt != null) - { - val callback = object : Callback - { - override fun onFailure(error: AuthenticationException) - { + if (attempt != null) { + val callback = object : Callback { + /******************************************************************************* + ** Cancel only the owning attempt and display a safe failure without exposing provider exception text. + *******************************************************************************/ + override fun onFailure(error: AuthenticationException) { scope.launch { - if(qViewModel.isCurrentAuthentication(attempt)) - { + if (qViewModel.isCurrentAuthentication(attempt)) { qViewModel.cancelAuthentication(attempt) failed = true } } } - override fun onSuccess(result: Credentials) - { + + /******************************************************************************* + ** Accept browser credentials only through the original current authentication attempt. + *******************************************************************************/ + override fun onSuccess(result: Credentials) { scope.launch { - if(qViewModel.isCurrentAuthentication(attempt)) - { + if (qViewModel.isCurrentAuthentication(attempt)) { exchanging = true qViewModel.completeAuth0Login(attempt, result.accessToken, result.user.nickname ?: "Unknown") } } } } - try { launcher.login(context, QAuth0Service.makeAuth0Instance(qViewModel), metadata.values.audience, QAuth0Service.scheme, callback) } - catch(exception: Exception) { qViewModel.cancelAuthentication(attempt); failed = true } + try { + launcher.login(context, QAuth0Service.makeAuth0Instance(qViewModel), metadata.values.audience, QAuth0Service.scheme, callback) + } catch (exception: Exception) { + qViewModel.cancelAuthentication(attempt); failed = true + } } - onDispose { if(attempt != null) qViewModel.cancelAuthentication(attempt) } + onDispose { if (attempt != null) qViewModel.cancelAuthentication(attempt) } } - if(failed || (exchanging && qViewModel.manageSessionLoadState is LoadState.Error)) - { - FullSizedAllCenteredColumn { + if (failed || (exchanging && qViewModel.manageSessionLoadState is LoadState.Error)) { + FullSizedAllCenteredColumn(modifier) { Text("Sign-in failed. Please try again.") Button(onClick = { failed = false; exchanging = false; retry++ }) { Text("Try again") } } - } - else FullScreenLoading(if(exchanging) "Creating Application Session…" else "Logging in…") + } else FullScreenLoading(if (exchanging) "Creating Application Session…" else "Logging in…", modifier) } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Logout.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Logout.kt index fdf1078..19dcfac 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Logout.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Logout.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -37,9 +38,11 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullSizedAllCenter import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import kotlinx.coroutines.launch +/******************************************************************************* + ** Complete local QQQ invalidation before the explicitly selected provider browser sign-out. + *******************************************************************************/ @Composable -fun QAuth0Logout(qViewModel: QViewModel, modifier: Modifier = Modifier) -{ +fun QAuth0Logout(qViewModel: QViewModel, modifier: Modifier = Modifier) { val context = LocalContext.current val launcher = LocalAuth0Browser.current val scope = rememberCoroutineScope() @@ -48,18 +51,29 @@ fun QAuth0Logout(qViewModel: QViewModel, modifier: Modifier = Modifier) { // Local QQQ sign-out must not depend on a browser callback (including cancellation or failure). qViewModel.logOut() - if(!qViewModel.claimAuth0ProviderLogout()) return@LaunchedEffect - val callback = object : Callback - { - override fun onFailure(error: AuthenticationException) - { scope.launch { providerMessage = "Provider sign-out could not be confirmed. Your provider may still be signed in." } } - override fun onSuccess(result: Void?) - { scope.launch { providerMessage = "Provider sign-out confirmed." } } + if (!qViewModel.claimAuth0ProviderLogout()) return@LaunchedEffect + val callback = object : Callback { + /******************************************************************************* + ** Report provider sign-out failure without restoring the already invalidated QQQ identity. + *******************************************************************************/ + override fun onFailure(error: AuthenticationException) { + scope.launch { providerMessage = "Provider sign-out could not be confirmed. Your provider may still be signed in." } + } + + /******************************************************************************* + ** Record provider completion without adopting credentials or reopening private navigation. + *******************************************************************************/ + override fun onSuccess(result: Void?) { + scope.launch { providerMessage = "Provider sign-out confirmed." } + } + } + try { + launcher.logout(context, QAuth0Service.makeAuth0Instance(qViewModel), QAuth0Service.scheme, callback) + } catch (exception: Exception) { + providerMessage = "Provider sign-out could not be started." } - try { launcher.logout(context, QAuth0Service.makeAuth0Instance(qViewModel), QAuth0Service.scheme, callback) } - catch(exception: Exception) { providerMessage = "Provider sign-out could not be started." } } - FullSizedAllCenteredColumn { + FullSizedAllCenteredColumn(modifier) { Text(qViewModel.logoutMessage.ifBlank { "Signing out…" }) Text(providerMessage) Button(onClick = { qViewModel.requestLogin() }, enabled = !qViewModel.logoutInProgress) { Text("Log back in") } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Service.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Service.kt index 33d33df..e6fe25c 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Service.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Service.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -28,37 +29,30 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/*************************************************************************** +/******************************************************************************* ** class to work with auth0 - ***************************************************************************/ -object QAuth0Service -{ + *******************************************************************************/ +object QAuth0Service { lateinit var scheme: String private const val TAG = "QAuth0Service" - /*************************************************************************** - ** - ***************************************************************************/ - fun makeAuth0Instance(qViewModel: QViewModel): Auth0 - { - if(!::scheme.isInitialized) - { + /******************************************************************************* + *******************************************************************************/ + fun makeAuth0Instance(qViewModel: QViewModel): Auth0 { + if (!::scheme.isInitialized) { throw (IllegalStateException("QAuth0Service.scheme was not initialized!")) } - when (val loadState = qViewModel.authenticationMetaDataLoadState) - { - is LoadState.Success -> - { + when (val loadState = qViewModel.authenticationMetaDataLoadState) { + is LoadState.Success -> { val authenticationMetaData = loadState.value as Auth0AuthenticationMetaData val auth0Instance: Auth0 = Auth0.getInstance(authenticationMetaData.values.clientId, authenticationMetaData.values.baseUrl) Log.d(TAG, "clientId: ${auth0Instance.clientId}, domain: ${auth0Instance.domain}") return auth0Instance } - else -> - { + else -> { throw (IllegalStateException("authenticationMetaData is not loaded.")) } } @@ -66,4 +60,4 @@ object QAuth0Service } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuthenticationWrapper.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuthenticationWrapper.kt index 44d6d78..904869e 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuthenticationWrapper.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuthenticationWrapper.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -32,13 +33,12 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.Reloader import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/*************************************************************************** +/******************************************************************************* ** Composable that wraps our entire application - providing a login form ** when needed, or a logout screen, else showing the app. - ***************************************************************************/ + *******************************************************************************/ @Composable -fun QAuthenticationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, qNavigator: QNavigator? = null) -{ +fun QAuthenticationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, qNavigator: QNavigator? = null) { val isAuthenticated = qViewModel.isAuthenticated; val authenticationMetaDataLoadState = qViewModel.authenticationMetaDataLoadState; val initialLoadFromDataStoreLoadState = qViewModel.initialLoadFromDataStoreLoadState; @@ -55,16 +55,11 @@ fun QAuthenticationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier { val authenticationMetaData = (authenticationMetaDataLoadState as LoadState.Success).value - if(qViewModel.isLogoutRequested) - { + if (qViewModel.isLogoutRequested) { QLogoutForm(qViewModel, authenticationMetaData) - } - else if(isAuthenticated) - { + } else if (isAuthenticated) { LocalAuthenticatedContent.current(qViewModel) { QApplicationWrapper(qViewModel, Modifier, qNavigator) } - } - else - { + } else { QLoginForm(qViewModel, authenticationMetaData) } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLoginForm.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLoginForm.kt index f816b00..c84dea3 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLoginForm.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLoginForm.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -37,24 +38,20 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import java.util.UUID -/*************************************************************************** +/******************************************************************************* ** Display an appropriate login form, based on the authenticationMetaData ** type. - ***************************************************************************/ + *******************************************************************************/ @Composable -fun QLoginForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticationMetaData, modifier: Modifier = Modifier) -{ - when (authenticationMetaData) - { +fun QLoginForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticationMetaData, modifier: Modifier = Modifier) { + when (authenticationMetaData) { is TableBasedAuthenticationMetaData -> QPasswordLogin(qViewModel, modifier) is OAuth2AuthenticationMetaData -> QOAuth2Login(qViewModel, authenticationMetaData, modifier) - is Auth0AuthenticationMetaData -> - { + is Auth0AuthenticationMetaData -> { QAuth0Login(qViewModel, modifier) } - is FullyAnonymousAuthenticationMetaData -> - { + is FullyAnonymousAuthenticationMetaData -> { FullSizedAllCenteredColumn() { Text("Click to log in Anonymously:", modifier = Modifier.padding(bottom = 16.dp)) @@ -66,8 +63,7 @@ fun QLoginForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticatio } } - else -> - { + else -> { Text("Error: Unrecognized AuthenticationMetaData type: ${authenticationMetaData.javaClass.simpleName}", color = Color.Red) } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLogoutForm.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLogoutForm.kt index 84f5b27..9cd5bd7 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLogoutForm.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLogoutForm.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -37,26 +38,22 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullSizedAllCenter import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/*************************************************************************** +/******************************************************************************* ** Display an appropriate logout screen/form, based on the authenticationMetaData ** type. - ***************************************************************************/ + *******************************************************************************/ @Composable -fun QLogoutForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticationMetaData, modifier: Modifier = Modifier) -{ +fun QLogoutForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticationMetaData, modifier: Modifier = Modifier) { qViewModel.companyLogoutBinding?.let { QOAuth2Logout(qViewModel, it) return } - when (authenticationMetaData) - { - is Auth0AuthenticationMetaData -> - { + when (authenticationMetaData) { + is Auth0AuthenticationMetaData -> { QAuth0Logout(qViewModel, modifier) } - is FullyAnonymousAuthenticationMetaData, is TableBasedAuthenticationMetaData, is OAuth2AuthenticationMetaData -> - { + is FullyAnonymousAuthenticationMetaData, is TableBasedAuthenticationMetaData, is OAuth2AuthenticationMetaData -> { LaunchedEffect(true) { /////////////////////////////////////////////////////////////////// @@ -76,8 +73,7 @@ fun QLogoutForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticati } } - else -> - { + else -> { Text("Error: Unrecognized AuthenticationMetaData type: ${authenticationMetaData.javaClass.simpleName}", color = Color.Red) } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Login.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Login.kt index 6689dc7..fe652d4 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Login.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Login.kt @@ -38,13 +38,16 @@ import java.net.URL import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -/** Hosts/tests may supply discovery transport; returned metadata is still validated before authorization. */ +/******************************************************************************* + ** Hosts/tests may supply discovery transport; returned metadata is still validated before authorization. + *******************************************************************************/ val LocalOAuth2Discovery = staticCompositionLocalOf AuthorizationServiceConfiguration> { ::discoverOAuth2 } -/** Browser authorization is standard AppAuth; API1.10 exchanges the code and validates the retained original nonce. */ +/******************************************************************************* + ** Browser authorization is standard AppAuth; API1.10 exchanges the code and validates the retained original nonce. + *******************************************************************************/ @Composable -fun QOAuth2Login(viewModel: QViewModel, metadata: OAuth2AuthenticationMetaData, modifier: Modifier = Modifier) -{ +fun QOAuth2Login(viewModel: QViewModel, metadata: OAuth2AuthenticationMetaData, modifier: Modifier = Modifier) { val context = LocalContext.current val binding = remember(metadata, viewModel.environment.baseUrl, context.packageName) { oauth2LoginBinding(metadata, viewModel.environment.baseUrl, context.packageName) @@ -56,74 +59,70 @@ fun QOAuth2Login(viewModel: QViewModel, metadata: OAuth2AuthenticationMetaData, { onDispose { service.dispose() - if(context.activity()?.isChangingConfigurations != true) viewModel.cancelOAuth2Login() + if (context.activity()?.isChangingConfigurations != true) viewModel.cancelOAuth2Login() } } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val response = runCatching { result.data?.let(AuthorizationResponse::fromIntent) }.getOrNull() - if(result.resultCode != Activity.RESULT_OK || response == null || binding == null) - { + if (result.resultCode != Activity.RESULT_OK || response == null || binding == null) { viewModel.cancelOAuth2Login("Sign-in was cancelled or could not be completed. Please try again.") - } - else - { - viewModel.completeOAuth2Login(binding, response.state, response.request.state, response.request.nonce, - response.request.clientId, response.request.redirectUri.toString(), response.request.prompt, response.authorizationCode) + } else { + viewModel.completeOAuth2Login( + binding, response.state, response.request.state, response.request.nonce, + response.request.clientId, response.request.redirectUri.toString(), response.request.prompt, response.authorizationCode + ) } } Column(modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text("Sign in") - if(binding == null) - { + if (binding == null) { Text("This server has not configured compatible native browser sign-in with required nonce validation. Ask your administrator to enable it.") - } - else - { + } else { Text("Continue in your browser to sign in with your organization.") - Button(modifier = Modifier.testTag("oauth2.signIn"), enabled = viewModel.oauth2LoginPhase == OAuth2LoginPhase.READY, + Button( + modifier = Modifier.testTag("oauth2.signIn"), enabled = viewModel.oauth2LoginPhase == OAuth2LoginPhase.READY, onClick = { val generation = viewModel.prepareOAuth2Login(binding) - if(generation != null) scope.launch { - try - { + if (generation != null) scope.launch { + try { viewModel.qqqRepository.verifyNativeSessionLogin() val configuration = discover(binding) check(validOAuth2Configuration(configuration, binding)) - if(viewModel.isCurrentOAuth2Login(generation, binding)) - { - val request = AuthorizationRequest.Builder(configuration, binding.clientId, ResponseTypeValues.CODE, - Uri.parse(binding.redirectUri)).setScope(binding.scopes).setPrompt(binding.prompt).setResponseMode("query").build() + if (viewModel.isCurrentOAuth2Login(generation, binding)) { + val request = AuthorizationRequest.Builder( + configuration, binding.clientId, ResponseTypeValues.CODE, + Uri.parse(binding.redirectUri) + ).setScope(binding.scopes).setPrompt(binding.prompt).setResponseMode("query").build() check(request.codeVerifierChallengeMethod == "S256") - if(viewModel.beginOAuth2Browser(generation, binding, requireNotNull(request.state), - requireNotNull(request.nonce), requireNotNull(request.codeVerifier))) + if (viewModel.beginOAuth2Browser( + generation, binding, requireNotNull(request.state), + requireNotNull(request.nonce), requireNotNull(request.codeVerifier) + ) + ) launcher.launch(service.getAuthorizationRequestIntent(request)) } - } - catch(exception: CancellationException) - { - if(viewModel.isCurrentOAuth2Login(generation, binding) && viewModel.oauth2LoginPhase == OAuth2LoginPhase.PREPARING) + } catch (exception: CancellationException) { + if (viewModel.isCurrentOAuth2Login(generation, binding) && viewModel.oauth2LoginPhase == OAuth2LoginPhase.PREPARING) viewModel.cancelOAuth2Login() throw exception - } - catch(exception: UiApiCompatibilityException) - { - if(viewModel.isCurrentOAuth2Login(generation, binding)) + } catch (exception: UiApiCompatibilityException) { + if (viewModel.isCurrentOAuth2Login(generation, binding)) viewModel.cancelOAuth2Login("This server does not support native sign-in with required nonce validation. Ask your administrator to upgrade or enable it.") - } - catch(exception: Exception) - { - if(viewModel.isCurrentOAuth2Login(generation, binding)) + } catch (exception: Exception) { + if (viewModel.isCurrentOAuth2Login(generation, binding)) viewModel.cancelOAuth2Login("Browser sign-in is unavailable. Check your connection, browser and server configuration.") } } - }) { Text(if(viewModel.oauth2LoginPhase == OAuth2LoginPhase.READY) "Sign in with organization" else "Signing in…") } + }) { Text(if (viewModel.oauth2LoginPhase == OAuth2LoginPhase.READY) "Sign in with organization" else "Signing in…") } } viewModel.oauth2LoginMessage?.let { Text(it, Modifier.testTag("oauth2.message")) } } } -/** AppAuth parses discovery. Restrict its transport and compare issuer to the explicit semantic contract. */ +/******************************************************************************* + ** AppAuth parses discovery. Restrict its transport and compare issuer to the explicit semantic contract. + *******************************************************************************/ private suspend fun discoverOAuth2(binding: OAuth2LoginBinding): AuthorizationServiceConfiguration = suspendCancellableCoroutine { continuation -> val connection = ConnectionBuilder { uri -> require(uri.toString() == binding.discoveryUrl && isOAuth2HttpsUrl(uri.toString())) @@ -134,21 +133,25 @@ private suspend fun discoverOAuth2(binding: OAuth2LoginBinding): AuthorizationSe } } AuthorizationServiceConfiguration.fetchFromUrl(Uri.parse(binding.discoveryUrl), { configuration, _ -> - if(continuation.isActive) - { - if(configuration != null && validOAuth2Configuration(configuration, binding)) + if (continuation.isActive) { + if (configuration != null && validOAuth2Configuration(configuration, binding)) continuation.resume(configuration) else continuation.resumeWithException(IllegalStateException("Invalid identity provider configuration")) } }, connection) } +/******************************************************************************* + ** Bind discovered endpoints to the advertised issuer and require direct HTTPS before authorization. + *******************************************************************************/ private fun validOAuth2Configuration(configuration: AuthorizationServiceConfiguration, binding: OAuth2LoginBinding): Boolean = configuration.discoveryDoc?.issuer?.toString() == binding.issuer && isOAuth2HttpsUrl(configuration.authorizationEndpoint.toString()) && isOAuth2HttpsUrl(configuration.tokenEndpoint.toString()) -private fun Context.activity(): Activity? = when(this) -{ +/******************************************************************************* + ** Unwrap Android context wrappers until the external browser can use the owning activity. + *******************************************************************************/ +private fun Context.activity(): Activity? = when (this) { is Activity -> this is ContextWrapper -> baseContext.activity() else -> null diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Logout.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Logout.kt index 9b93dc5..c1c7e38 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Logout.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Logout.kt @@ -22,59 +22,67 @@ import net.openid.appauth.AuthorizationService import net.openid.appauth.EndSessionRequest import net.openid.appauth.EndSessionResponse -/** Explicit host policy; default UI and ordinary sign-out never invoke global provider logout. */ +/******************************************************************************* + ** Explicit host policy; default UI and ordinary sign-out never invoke global provider logout. + *******************************************************************************/ val LocalCompanySignOutEnabled = staticCompositionLocalOf { false } -/** Shares the registered receiver, but consumes only AppAuth end-session responses and state. */ +/******************************************************************************* + ** Shares the registered receiver, but consumes only AppAuth end-session responses and state. + *******************************************************************************/ @Composable -fun QOAuth2Logout(model: QViewModel, binding: OAuth2LogoutBinding) -{ +fun QOAuth2Logout(model: QViewModel, binding: OAuth2LogoutBinding) { val context = LocalContext.current val service = remember(context) { AuthorizationService(context) } val discover = LocalOAuth2Discovery.current val generation = model.companyLogoutGeneration val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val response = runCatching { result.data?.let(EndSessionResponse::fromIntent) }.getOrNull() - if(result.resultCode != Activity.RESULT_OK || response == null) + if (result.resultCode != Activity.RESULT_OK || response == null) model.finishCompanyLogout(generation, binding) - else model.completeCompanyLogout(generation, binding, response.state, response.request.state, - response.request.configuration.endSessionEndpoint?.toString(), response.request.postLogoutRedirectUri?.toString()) + else model.completeCompanyLogout( + generation, binding, response.state, response.request.state, + response.request.configuration.endSessionEndpoint?.toString(), response.request.postLogoutRedirectUri?.toString() + ) } DisposableEffect(service) { onDispose { service.dispose() } } LaunchedEffect(model, binding, generation) { - if(model.companyLogoutPhase != OAuth2LogoutPhase.PREPARING) return@LaunchedEffect - try - { + if (model.companyLogoutPhase != OAuth2LogoutPhase.PREPARING) return@LaunchedEffect + try { val material = model.logOutWithProvider(generation, binding) - if(material == null) - { + if (material == null) { model.finishCompanyLogout(generation, binding) return@LaunchedEffect } val configuration = discover(binding.login) - check(validOAuth2LogoutData(material, binding, configuration.discoveryDoc?.issuer?.toString(), - configuration.endSessionEndpoint?.toString())) + check( + validOAuth2LogoutData( + material, binding, configuration.discoveryDoc?.issuer?.toString(), + configuration.endSessionEndpoint?.toString() + ) + ) // The hint crosses AppAuth Intent/saved-activity and browser URL boundaries; never log this request. val request = EndSessionRequest.Builder(configuration).setIdTokenHint(material.idTokenHint) .setPostLogoutRedirectUri(Uri.parse(binding.postLogoutRedirectUri)).build() - if(model.beginCompanyLogoutBrowser(generation, binding, material.endSessionEndpoint, requireNotNull(request.state))) + if (model.beginCompanyLogoutBrowser(generation, binding, material.endSessionEndpoint, requireNotNull(request.state))) launcher.launch(service.getEndSessionRequestIntent(request)) - } - catch(exception: CancellationException) - { + } catch (exception: CancellationException) { // A cancelled preparation is deliberately not replayed after recreation or server changes. model.finishCompanyLogout(generation, binding) throw exception + } catch (exception: Exception) { + model.finishCompanyLogout(generation, binding) } - catch(exception: Exception) { model.finishCompanyLogout(generation, binding) } } Column() { Text(model.logoutMessage.ifEmpty { "Signing out…" }, Modifier.testTag("oauth2.logoutMessage")) - if(model.companyLogoutPhase == OAuth2LogoutPhase.BROWSER) + if (model.companyLogoutPhase == OAuth2LogoutPhase.BROWSER) Button(onClick = { model.finishCompanyLogout(generation, binding) }) { Text("Cancel company sign-out") } - Button(enabled = !model.logoutInProgress && model.companyLogoutPhase == OAuth2LogoutPhase.FINISHED, - onClick = model::requestLogin) { Text("Log back in") } + Button( + enabled = !model.logoutInProgress && model.companyLogoutPhase == OAuth2LogoutPhase.FINISHED, + onClick = model::requestLogin + ) { Text("Log back in") } } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt index d2c7370..8e1c2ae 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt @@ -28,10 +28,11 @@ import androidx.compose.ui.unit.dp import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/** Native credential form for QQQ TABLE_BASED authentication. */ +/******************************************************************************* + ** Native credential form for QQQ TABLE_BASED authentication. + *******************************************************************************/ @Composable -fun QPasswordLogin(viewModel: QViewModel, modifier: Modifier = Modifier) -{ +fun QPasswordLogin(viewModel: QViewModel, modifier: Modifier = Modifier) { var username by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var submitted by remember { mutableStateOf(false) } @@ -41,22 +42,25 @@ fun QPasswordLogin(viewModel: QViewModel, modifier: Modifier = Modifier) Column(modifier.imePadding().verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text("Sign in") - OutlinedTextField(username, { username = it }, label = { Text("Username") }, singleLine = true, enabled = !busy, + OutlinedTextField( + username, { username = it }, label = { Text("Username") }, singleLine = true, enabled = !busy, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })) - OutlinedTextField(password, { password = it }, label = { Text("Password") }, singleLine = true, + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }) + ) + OutlinedTextField( + password, { password = it }, label = { Text("Password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), enabled = !busy, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboard?.hide() })) + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboard?.hide() }) + ) Button(enabled = !busy && username.isNotBlank() && password.isNotEmpty(), onClick = { focusManager.clearFocus() keyboard?.hide() submitted = true viewModel.loginWithPassword(username.trim(), password) password = "" - }) { Text(if(busy) "Signing in…" else "Sign in") } - if(submitted && viewModel.manageSessionLoadState is LoadState.Error) - { + }) { Text(if (busy) "Signing in…" else "Sign in") } + if (submitted && viewModel.manageSessionLoadState is LoadState.Error) { Text("Sign-in failed. Check your credentials and connection.") } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/common/FullScreenLoading.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/common/FullScreenLoading.kt index 8e4b075..f70a15e 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/common/FullScreenLoading.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/common/FullScreenLoading.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -30,12 +31,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun FullScreenLoading(loadingText: String, modifier: Modifier = Modifier) -{ +fun FullScreenLoading(loadingText: String, modifier: Modifier = Modifier) { FullSizedAllCenteredColumn(modifier) { Text(loadingText, color = Color.Gray, modifier = Modifier.padding(bottom = 16.dp)) @@ -46,4 +45,4 @@ fun FullScreenLoading(loadingText: String, modifier: Modifier = Modifier) trackColor = MaterialTheme.colorScheme.surfaceVariant, ) } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/common/FullSizedAllCenteredColumn.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/common/FullSizedAllCenteredColumn.kt index 0167271..c3d3af4 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/common/FullSizedAllCenteredColumn.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/common/FullSizedAllCenteredColumn.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -28,15 +29,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun FullSizedAllCenteredColumn(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) -{ +fun FullSizedAllCenteredColumn(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = modifier.fillMaxSize(), - content = content) -} \ No newline at end of file + content = content + ) +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/MenuDialog.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/MenuDialog.kt index e43231b..e7c912c 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/MenuDialog.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/MenuDialog.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -38,28 +39,32 @@ import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.Dropdown import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/** Secondary settings preserve the active process until a server change is confirmed. */ +/******************************************************************************* + ** Secondary settings preserve the active process until a server change is confirmed. + *******************************************************************************/ @Composable -fun MenuDialog(qViewModel: QViewModel, appTitle: String, isMenuDialogOpen: MutableState) -{ +fun MenuDialog(qViewModel: QViewModel, appTitle: String, isMenuDialogOpen: MutableState) { val selected = remember { mutableStateOf(qViewModel.environment) } - AlertDialog(onDismissRequest = { isMenuDialogOpen.value = false }, + AlertDialog( + onDismissRequest = { isMenuDialogOpen.value = false }, title = { Text("Settings", Modifier.testTag("menuDialog.title")) }, - text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(appTitle) - Text("Server: ${qViewModel.environment.baseUrl}") - LocalDeviceSettings.current() - if(QAppContainer.availableEnvironments.size > 1) - { - Dropdown(value = selected.value, options = QAppContainer.availableEnvironments, - labelExtractor = { it.label }, onClick = { selected.value = it }) - TextButton(enabled = selected.value != qViewModel.environment, onClick = { - isMenuDialogOpen.value = false - qViewModel.qNavigator.requestLeave("Change server and sign out?", "Change server", alwaysConfirm = true) { - qViewModel.switchEnvironment(selected.value) {} - } - }) { Text("Switch") } + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(appTitle) + Text("Server: ${qViewModel.environment.baseUrl}") + LocalDeviceSettings.current() + if (QAppContainer.availableEnvironments.size > 1) { + Dropdown( + value = selected.value, options = QAppContainer.availableEnvironments, + labelExtractor = { it.label }, onClick = { selected.value = it }) + TextButton(enabled = selected.value != qViewModel.environment, onClick = { + isMenuDialogOpen.value = false + qViewModel.qNavigator.requestLeave("Change server and sign out?", "Change server", alwaysConfirm = true) { + qViewModel.switchEnvironment(selected.value) {} + } + }) { Text("Switch") } + } + Text("Application version: ${QViewModel.applicationVersion}") } - Text("Application version: ${QViewModel.applicationVersion}") - } }, confirmButton = { TextButton(onClick = { isMenuDialogOpen.value = false }) { Text("Close") } }) + }, confirmButton = { TextButton(onClick = { isMenuDialogOpen.value = false }) { Text("Close") } }) } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NavigationSurfaces.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NavigationSurfaces.kt index ff53613..04b63c1 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NavigationSurfaces.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NavigationSurfaces.kt @@ -26,70 +26,86 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAu import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.oauth2LogoutBinding -/** Host-owned device settings and diagnostics; these surfaces do not change the business route. */ +/******************************************************************************* + ** Host-owned device settings and diagnostics; these surfaces do not change the business route. + *******************************************************************************/ val LocalDeviceSettings = compositionLocalOf<@Composable () -> Unit> { {} } val LocalOpenDiagnostics = compositionLocalOf<(() -> Unit)?> { null } val LocalBusinessNavigationEnabled = compositionLocalOf { true } +/******************************************************************************* + ** Expose account actions while keeping optional company sign-out separate from ordinary local sign-out. + *******************************************************************************/ @Composable -internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) -{ - val companyLogout = if(LocalCompanySignOutEnabled.current) +internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) { + val companyLogout = if (LocalCompanySignOutEnabled.current) ((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let { oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName) } else null var supportsCompanyLogout by remember(model, companyLogout) { mutableStateOf(false) } LaunchedEffect(model, companyLogout) { - if(companyLogout != null) try { supportsCompanyLogout = model.qqqRepository.supportsNativeSessionLogout() } - catch(exception: kotlinx.coroutines.CancellationException) { throw exception } - catch(exception: Exception) { supportsCompanyLogout = false } + if (companyLogout != null) try { + supportsCompanyLogout = model.qqqRepository.supportsNativeSessionLogout() + } catch (exception: kotlinx.coroutines.CancellationException) { + throw exception + } catch (exception: Exception) { + supportsCompanyLogout = false + } } - AlertDialog(onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) }, - text = { Column { - Text(if(model.isAuthenticated) "Current User: ${model.sessionUserFullName}" else "Not currently logged in") - TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = { - dismiss() - model.qNavigator.requestSignOut() - }) { Text("Sign out") } - if(companyLogout != null && supportsCompanyLogout) + AlertDialog( + onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) }, + text = { + Column { + Text(if (model.isAuthenticated) "Current User: ${model.sessionUserFullName}" else "Not currently logged in") TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = { dismiss() - model.qNavigator.requestCompanySignOut(companyLogout) - }) { Text("Sign out of company") } - } }, confirmButton = { TextButton(onClick = dismiss) { Text("Close") } }) + model.qNavigator.requestSignOut() + }) { 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") } }) } +/******************************************************************************* + ** Make cancellation and uncertain work explicit before the requested destination replaces the current process. + *******************************************************************************/ @Composable -internal fun LeaveProcessDialog(model: QViewModel) -{ +internal fun LeaveProcessDialog(model: QViewModel) { val navigator = model.qNavigator val request = navigator.leaveRequest ?: return val process = model.activeProcessViewModel val blocked = process?.navigationBlockReason() val cancelling = process?.cancelling == true - AlertDialog(onDismissRequest = navigator::dismissLeave, title = { Text(request.title) }, - text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { - if(process != null && !process.getNoMoreSteps()) - { - Text("Leaving cancels this process. Unsaved input will be lost. Saved scans and other committed records stay saved.") - process.navigationWarning()?.let { Text(it) } - if(process.unconfirmedSubmission && process.pendingStepValues == null) - Text("The last request's result is unknown. Cancellation does not undo saved changes. Ask your administrator to check process ${process.processUUID}.") + AlertDialog( + onDismissRequest = navigator::dismissLeave, title = { Text(request.title) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (process != null && !process.getNoMoreSteps()) { + Text("Leaving cancels this process. Unsaved input will be lost. Saved scans and other committed records stay saved.") + process.navigationWarning()?.let { Text(it) } + if (process.unconfirmedSubmission && process.pendingStepValues == null) + Text("The last request's result is unknown. Cancellation does not undo saved changes. Ask your administrator to check process ${process.processUUID}.") + } else if (request.confirmLabel == "Sign out") Text("Sign out of this account on this device?") + if (request.confirmLabel == "Sign out of company") + Text("Sign out of QQQ and continue in your browser to sign out of your organization? This may affect other company applications in that browser.") + blocked?.let { Text(it) } + if (cancelling) Text("Waiting for the server to confirm cancellation…") + if (process?.cancellationFailed == true) + Text("The server did not confirm cancellation. You can retry cancellation or leave anyway. The server process may still be running and the scan may already be saved. Leaving anyway stops local retries; it does not undo saved changes.") + (process?.topLevelErrorState as? LoadState.Error)?.message?.let { Text(it) } } - else if(request.confirmLabel == "Sign out") Text("Sign out of this account on this device?") - if(request.confirmLabel == "Sign out of company") - Text("Sign out of QQQ and continue in your browser to sign out of your organization? This may affect other company applications in that browser.") - blocked?.let { Text(it) } - if(cancelling) Text("Waiting for the server to confirm cancellation…") - if(process?.cancellationFailed == true) - Text("The server did not confirm cancellation. You can retry cancellation or leave anyway. The server process may still be running and the scan may already be saved. Leaving anyway stops local retries; it does not undo saved changes.") - (process?.topLevelErrorState as? LoadState.Error)?.message?.let { Text(it) } - } }, + }, confirmButton = { TextButton(onClick = navigator::confirmLeave, enabled = blocked == null && !cancelling) { Text(request.confirmLabel) } }, - dismissButton = { Column { - if(process?.cancellationFailed == true) - TextButton(onClick = navigator::leaveAnyway, enabled = blocked == null && !cancelling) { Text("Leave anyway") } - TextButton(onClick = navigator::dismissLeave, enabled = !cancelling) { Text("Stay") } - } }) + dismissButton = { + Column { + if (process?.cancellationFailed == true) + TextButton(onClick = navigator::leaveAnyway, enabled = blocked == null && !cancelling) { Text("Leave anyway") } + TextButton(onClick = navigator::dismissLeave, enabled = !cancelling) { Text("Stay") } + } + }) } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NetworkPresence.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NetworkPresence.kt index 041242b..2902dc4 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NetworkPresence.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NetworkPresence.kt @@ -1,19 +1,24 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe -/** Aggregate network callbacks: losing one transport must not hide another available network. */ -internal class NetworkPresence -{ +/******************************************************************************* + ** Aggregate network callbacks: losing one transport must not hide another available network. + *******************************************************************************/ +internal class NetworkPresence { private val handles = mutableSetOf() - fun onAvailable(networkHandle: Long): Boolean - { + /******************************************************************************* + ** Track available networks by identity so one network's arrival cannot hide another network's loss. + *******************************************************************************/ + fun onAvailable(networkHandle: Long): Boolean { handles.add(networkHandle) return handles.isNotEmpty() } - fun onLost(networkHandle: Long): Boolean - { + /******************************************************************************* + ** Remove only the lost network before deriving whether any usable network remains. + *******************************************************************************/ + fun onLost(networkHandle: Long): Boolean { handles.remove(networkHandle) return handles.isNotEmpty() } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NoNetworkDialog.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NoNetworkDialog.kt index ba56157..9995f4a 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NoNetworkDialog.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NoNetworkDialog.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -41,11 +42,10 @@ import androidx.compose.ui.unit.sp import com.kingsrook.qqq.frontend.android.mobileapp.R import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun NoNetworkDialog(qViewModel: QViewModel) +fun NoNetworkDialog(@Suppress("UNUSED_PARAMETER") qViewModel: QViewModel) // The caller observes connectivity; retain this published dialog signature for existing hosts. { AlertDialog( title = { }, @@ -72,4 +72,4 @@ fun NoNetworkDialog(qViewModel: QViewModel) dismissButton = { }, confirmButton = { }, ) -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QApplicationWrapper.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QApplicationWrapper.kt index 86704a3..eb951ed 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QApplicationWrapper.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QApplicationWrapper.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -44,20 +45,19 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import kotlinx.serialization.Serializable -/*************************************************************************** +/******************************************************************************* ** Composable that wraps our entire application - providing a login form ** when needed, else showing the app. - ***************************************************************************/ + *******************************************************************************/ @Composable -fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, qNavigator: QNavigator? = null) -{ +fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, qNavigator: QNavigator? = null) { val qInstanceLoadState = qViewModel.qInstanceLoadState val loadStates = listOf(qInstanceLoadState) val navController = qNavigator?.navController ?: rememberNavController() val hostBackAction = LocalNavigationBackAction.current BackHandler(enabled = qNavigator?.atHome == false) { - if(hostBackAction != null) hostBackAction() else qNavigator?.popStack() + if (hostBackAction != null) hostBackAction() else qNavigator?.popStack() } LoadStateView( @@ -81,12 +81,9 @@ fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, q { backStackEntry -> val appRoute: AppRoute = backStackEntry.toRoute() val app = qInstance.apps?.get(appRoute.name) - if(app != null) - { + if (app != null) { QAppHome(qInstance, modifier, app = app, qNavigator = qNavigator) - } - else - { + } else { NotFound(qNavigator, "App not found (name=${appRoute.name})") } } @@ -95,12 +92,9 @@ fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, q { backStackEntry -> val processRoute: ProcessRoute = backStackEntry.toRoute() val process = qInstance.processes?.get(processRoute.name) - if(process != null) - { + if (process != null) { QProcessHome(qViewModel, qInstance, modifier) - } - else - { + } else { NotFound(qNavigator, "Process not found (name=${processRoute.name})") } } @@ -110,12 +104,10 @@ fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, q } } -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -private fun NotFound(qNavigator: QNavigator?, message: String) -{ +private fun NotFound(qNavigator: QNavigator?, message: String) { FullSizedAllCenteredColumn() { Text(message, modifier = Modifier.padding(bottom = 16.dp)) @@ -126,21 +118,21 @@ private fun NotFound(qNavigator: QNavigator?, message: String) } } -/*************************************************************************** +/******************************************************************************* ** route/data (singleton) to represent the home screen - ***************************************************************************/ + *******************************************************************************/ @Serializable object HomeRoute -/*************************************************************************** +/******************************************************************************* ** route/data to represent an app - ***************************************************************************/ + *******************************************************************************/ @Serializable data class AppRoute(val name: String) -/*************************************************************************** +/******************************************************************************* ** route/data to represent a process - ***************************************************************************/ + *******************************************************************************/ @Serializable data class ProcessRoute(val name: String, val appName: String? = null) diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QNavigationDrawer.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QNavigationDrawer.kt index 0c3992f..297ecaf 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QNavigationDrawer.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QNavigationDrawer.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -44,63 +45,58 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.TopLevelAppState -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun QNavigationDrawer(topLevelAppState: TopLevelAppState, content: @Composable ColumnScope.() -> Unit) -{ +fun QNavigationDrawer(topLevelAppState: TopLevelAppState, content: @Composable ColumnScope.() -> Unit) { ModalNavigationDrawer( drawerState = topLevelAppState.navDrawerState, drawerContent = - { - ModalDrawerSheet(modifier = Modifier.padding(top = 24.dp)) { - Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) + ModalDrawerSheet(modifier = Modifier.padding(top = 24.dp)) { - Text("QNav Drawer title", fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp)) - - IconButton(onClick = topLevelAppState::closeNavDrawer) + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - Icon(imageVector = Icons.Filled.Close, contentDescription = "Close Menu") - } - } - HorizontalDivider() + Text("QNav Drawer title", fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp)) - if(topLevelAppState.isLoggedIn()) - { - NavigationDrawerItem( - label = + IconButton(onClick = topLevelAppState::closeNavDrawer) { - Text(text = "Log Out") - }, - icon = { Icon(imageVector = Icons.Default.AccountCircle, contentDescription = "Log Out") }, - selected = false, - onClick = - { - topLevelAppState.logOut() - topLevelAppState.closeNavDrawer() + Icon(imageVector = Icons.Filled.Close, contentDescription = "Close Menu") } - ) - } - else - { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(16.dp) - ) - { - Icon(imageVector = Icons.Default.AccountCircle, contentDescription = "Log Out", tint = Color.Gray) - Text("Log Out", color = Color.Gray) } - } + HorizontalDivider() - // ...other drawer items + if (topLevelAppState.isLoggedIn()) { + NavigationDrawerItem( + label = + { + Text(text = "Log Out") + }, + icon = { Icon(imageVector = Icons.Default.AccountCircle, contentDescription = "Log Out") }, + selected = false, + onClick = + { + topLevelAppState.logOut() + topLevelAppState.closeNavDrawer() + } + ) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(16.dp) + ) + { + Icon(imageVector = Icons.Default.AccountCircle, contentDescription = "Log Out", tint = Color.Gray) + Text("Log Out", color = Color.Gray) + } + } + + // ...other drawer items + } } - } ) { Column(modifier = Modifier, content = content) } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QTopAppBar.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QTopAppBar.kt index 74937a1..f9770cc 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QTopAppBar.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QTopAppBar.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -52,35 +53,32 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.QNavigator import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalNavigationBackAction import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @OptIn(ExperimentalMaterial3Api::class) // needed for TopAppBar @Composable -fun QTopAppBar(qViewModel: QViewModel, defaultTitle: String = "A QQQ Android Application", navMenuCallback: (() -> Unit)? = null, qNavigator: QNavigator? = null, showNetworkDialog: Boolean = true, accountOpenState: MutableState? = null) -{ +fun QTopAppBar(qViewModel: QViewModel, defaultTitle: String = "A QQQ Android Application", navMenuCallback: (() -> Unit)? = null, qNavigator: QNavigator? = null, showNetworkDialog: Boolean = true, accountOpenState: MutableState? = null) { val accountOpen = accountOpenState ?: remember { mutableStateOf(false) } val hostBackAction = LocalNavigationBackAction.current val authenticated = qViewModel.isAuthenticated && !qViewModel.isLogoutRequested val businessReady = authenticated && LocalBusinessNavigationEnabled.current LaunchedEffect(qViewModel.isLogoutRequested) { - if(qViewModel.isLogoutRequested) - { + if (qViewModel.isLogoutRequested) { accountOpen.value = false } } - val title = if(businessReady) qNavigator?.titleStack?.lastOrNull() ?: "Apps" else defaultTitle + val title = if (businessReady) qNavigator?.titleStack?.lastOrNull() ?: "Apps" else defaultTitle TopAppBar( colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primaryContainer), title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.testTag("topAppBar.title")) }, navigationIcon = { Row { - if(navMenuCallback != null) + if (navMenuCallback != null) IconButton(onClick = navMenuCallback, modifier = Modifier.testTag("topBar.menuIcon")) { Icon(Icons.Default.Menu, "Open navigation menu") } - if(businessReady && qNavigator?.atHome == false) + if (businessReady && qNavigator?.atHome == false) IconButton(onClick = hostBackAction ?: qNavigator::popStack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Go Back") } } }, @@ -89,21 +87,20 @@ fun QTopAppBar(qViewModel: QViewModel, defaultTitle: String = "A QQQ Android App { Icon(Icons.Default.AccountCircle, "Account") } } ) - if(accountOpen.value) AccountDialog(qViewModel) { accountOpen.value = false } + if (accountOpen.value) AccountDialog(qViewModel) { accountOpen.value = false } - if(showNetworkDialog) - { + if (showNetworkDialog) { NetworkConnectionDialog(qViewModel) } } - -/** Legacy internet-presence prompt; hosts with server reachability monitoring can omit it. */ +/******************************************************************************* + ** Legacy internet-presence prompt; hosts with server reachability monitoring can omit it. + *******************************************************************************/ @Composable -private fun NetworkConnectionDialog(qViewModel: QViewModel) -{ +private fun NetworkConnectionDialog(qViewModel: QViewModel) { val networkAvailable = remember { mutableStateOf(true) } val connectivityManager = getSystemService(LocalContext.current, ConnectivityManager::class.java) as ConnectivityManager DisposableEffect(connectivityManager) @@ -114,23 +111,25 @@ private fun NetworkConnectionDialog(qViewModel: QViewModel) .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) .build() - val callback = object : ConnectivityManager.NetworkCallback() - { - override fun onAvailable(network: Network) - { + val callback = object : ConnectivityManager.NetworkCallback() { + /******************************************************************************* + ** Update the network-presence model when Android reports an available network. + *******************************************************************************/ + override fun onAvailable(network: Network) { networkAvailable.value = networks.onAvailable(network.networkHandle) } - override fun onLost(network: Network) - { + /******************************************************************************* + ** Update the network-presence model for this lost network instead of unconditionally declaring the device offline. + *******************************************************************************/ + override fun onLost(network: Network) { networkAvailable.value = networks.onLost(network.networkHandle) } } connectivityManager.registerNetworkCallback(request, callback) onDispose { connectivityManager.unregisterNetworkCallback(callback) } } - if(!networkAvailable.value) - { + if (!networkAvailable.value) { NoNetworkDialog(qViewModel) } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/FrontendStepFormat.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/FrontendStepFormat.kt index ed0bb28..d0c9381 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/FrontendStepFormat.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/FrontendStepFormat.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,27 +25,44 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.processes import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.toLowerCase -/*************************************************************************** +/******************************************************************************* ** when a frontend step specifies a "format", that implies a number of ** different settings - which this class defines and its subclasses clarify. - ***************************************************************************/ -open class FrontendStepFormat -{ + *******************************************************************************/ +open class FrontendStepFormat { + /******************************************************************************* + ** Let the server-selected screen format control whether the standard step heading is rendered. + *******************************************************************************/ open fun isStepLabelDisplayed() = true + + /******************************************************************************* + ** Keep standard screen spacing overridable for formats whose controls fill the display. + *******************************************************************************/ open fun doesScreenGetPadding() = true + + /******************************************************************************* + ** Allow specialized formats to own submission controls instead of duplicating the standard footer. + *******************************************************************************/ open fun areStandardFooterButtonsDisplayed() = true + + /******************************************************************************* + ** Make submit audio an explicit format policy rather than an assumption about process names. + *******************************************************************************/ open fun playOnSubmitSound() = false + + /******************************************************************************* + ** Make loading audio opt-in so ordinary metadata screens stay silent. + *******************************************************************************/ open fun playOnLoadingSound() = false - companion object - { - /*************************************************************************** - ** - ***************************************************************************/ - fun of(format: String?): FrontendStepFormat - { - return when ((format ?: "").toLowerCase(Locale.current)) - { + /******************************************************************************* + ** Resolve advertised format names through a shared default instead of inventing process-specific behavior. + *******************************************************************************/ + companion object { + /******************************************************************************* + *******************************************************************************/ + fun of(format: String?): FrontendStepFormat { + return when ((format ?: "").toLowerCase(Locale.current)) { "scanner" -> ScannerFormat() else -> DefaultFormat() } @@ -53,38 +71,30 @@ open class FrontendStepFormat } } -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ class DefaultFormat : FrontendStepFormat() -/*************************************************************************** - ** - ***************************************************************************/ -class ScannerFormat : FrontendStepFormat() -{ - /*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ +class ScannerFormat : FrontendStepFormat() { + /******************************************************************************* + *******************************************************************************/ override fun isStepLabelDisplayed() = false - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ override fun doesScreenGetPadding() = false - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ override fun areStandardFooterButtonsDisplayed() = false - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ override fun playOnSubmitSound() = true - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ override fun playOnLoadingSound() = true -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessBottomButtons.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessBottomButtons.kt index b1bdc35..38656ad 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessBottomButtons.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessBottomButtons.kt @@ -1,3 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.mobileapp.ui.processes import androidx.compose.foundation.layout.Arrangement @@ -11,12 +32,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun ProcessBottomButtons(processViewModel: ProcessViewModel) -{ +fun ProcessBottomButtons(processViewModel: ProcessViewModel) { Row( horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth() @@ -26,11 +45,10 @@ fun ProcessBottomButtons(processViewModel: ProcessViewModel) Button(onClick = processViewModel::requestClose, enabled = !processViewModel.cancelling, modifier = Modifier.requiredWidth(100.dp)) { - Text(if(noMoreSteps) "Close" else "Cancel") + Text(if (noMoreSteps) "Close" else "Cancel") } - if(!noMoreSteps) - { + if (!noMoreSteps) { Button(onClick = { processViewModel.runStep() }, enabled = !processViewModel.waitingOnBackend && !processViewModel.cancelling && processViewModel.clientWorkBlockReason == null && processViewModel.unsupportedReason() == null, modifier = Modifier.requiredWidth(100.dp)) { Text(processViewModel.getNextButtonLabel()) diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessSound.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessSound.kt index 7396816..0e9f9ac 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessSound.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessSound.kt @@ -1,39 +1,49 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package com.kingsrook.qqq.frontend.android.mobileapp.ui.processes -/** One sound player per composed process screen, released on stop or disposal and recreated on demand. */ -internal class ProcessSound(private val create: () -> Playback?) -{ - /** Small platform boundary so lifecycle resource ownership can be verified without native audio. */ - interface Playback - { +/******************************************************************************* + ** One sound player per composed process screen, released on stop or disposal and recreated on demand. + *******************************************************************************/ +internal class ProcessSound(private val create: () -> Playback?) { + /******************************************************************************* + ** Small platform boundary so lifecycle resource ownership can be verified without native audio. + *******************************************************************************/ + interface Playback { + /******************************************************************************* + ** Begin optional process audio through an injected resource boundary so playback behavior is testable. + *******************************************************************************/ fun start() + + /******************************************************************************* + ** Release optional audio ownership when playback finishes or its process lifetime ends. + *******************************************************************************/ fun release() } private var player: Playback? = null - /** Repeated submits/ticks reuse the same live player. */ - fun play() - { - try - { - if(player == null) player = create() + /******************************************************************************* + ** Repeated submits/ticks reuse the same live player. + *******************************************************************************/ + fun play() { + try { + if (player == null) player = create() player?.start() - } - catch(exception: RuntimeException) - { + } catch (exception: RuntimeException) { // Optional audio feedback must not interrupt a process action when the device's audio service fails. release() } } - /** Stop and disposal may both fire; release each allocated player exactly once. */ - fun release() - { + /******************************************************************************* + ** Stop and disposal may both fire; release each allocated player exactly once. + *******************************************************************************/ + fun release() { val previous = player player = null - try { previous?.release() } - catch(exception: RuntimeException) { /* The audio service may already have reclaimed the resource. */ } + try { + previous?.release() + } catch (exception: RuntimeException) { /* The audio service may already have reclaimed the resource. */ + } } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessStepLoading.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessStepLoading.kt index b91e396..a9902a2 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessStepLoading.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessStepLoading.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -42,13 +43,12 @@ import kotlinx.coroutines.delay import java.time.format.DateTimeFormatter -/*************************************************************************** +/******************************************************************************* ** Loading screen within a process - e.g., when server-side async processing ** is running long - ***************************************************************************/ + *******************************************************************************/ @Composable -fun ProcessStepLoading(processViewModel: ProcessViewModel) -{ +fun ProcessStepLoading(processViewModel: ProcessViewModel) { val playCount = remember { mutableIntStateOf(0) } val playTick = rememberProcessSound(ProcessTone.LOADING) @@ -59,8 +59,7 @@ fun ProcessStepLoading(processViewModel: ProcessViewModel) LaunchedEffect(processViewModel.jobRunningLastUpdated) { delay(1000) - if(processViewModel.waitingOnBackend) - { + if (processViewModel.waitingOnBackend) { processViewModel.checkIn() } } @@ -69,13 +68,11 @@ fun ProcessStepLoading(processViewModel: ProcessViewModel) // use a separate effect, that will fire every second (regardless of if backend is responding), to play 'tick' // ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// val format = FrontendStepFormat.of(processViewModel.activeStep?.format) - if(format.playOnLoadingSound()) - { + if (format.playOnLoadingSound()) { LaunchedEffect(playCount.intValue) { - if(processViewModel.waitingOnBackend) - { - playTick() + if (processViewModel.waitingOnBackend) { + playTick() delay(1000) playCount.intValue += 1 } @@ -103,17 +100,13 @@ fun ProcessStepLoading(processViewModel: ProcessViewModel) .align(Alignment.CenterHorizontally) .padding(bottom = 4.dp) - if(processViewModel.jobRunningMessage != "") - { + if (processViewModel.jobRunningMessage != "") { Text(processViewModel.jobRunningMessage, modifier = commonModifier) - } - else - { + } else { Text("Working...", modifier = commonModifier) } - if(processViewModel.currentOfTotalMessage != "") - { + if (processViewModel.currentOfTotalMessage != "") { Text(processViewModel.currentOfTotalMessage, modifier = commonModifier) } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/QProcessHome.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/QProcessHome.kt index 80faf41..385f877 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/QProcessHome.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/QProcessHome.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -68,22 +69,19 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import kotlinx.coroutines.delay -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifier = Modifier) -{ +fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifier = Modifier) { val processViewModel = qViewModel.activeProcessViewModel - if(processViewModel == null) - { + if (processViewModel == null) { Text("Error: ProcessViewModel was not available.", color = Colors.ERROR) return } val processMetaDataLoadState = processViewModel.processMetaDataLoadState val hostBackAction = LocalNavigationBackAction.current - BackHandler { if(hostBackAction != null) hostBackAction() else qViewModel.qNavigator.popStack() } + BackHandler { if (hostBackAction != null) hostBackAction() else qViewModel.qNavigator.popStack() } val initLoadState = processViewModel.initLoadState val topLevelErrorState = processViewModel.topLevelErrorState val loadStates = listOf(processMetaDataLoadState, initLoadState, topLevelErrorState) @@ -92,9 +90,8 @@ fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifie processViewModel.onSubmitCallback = { val format = FrontendStepFormat.of(processViewModel.activeStep?.format) - if(format.playOnSubmitSound()) - { - playSubmitSound() + if (format.playOnSubmitSound()) { + playSubmitSound() } } @@ -107,8 +104,7 @@ fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifie /////////////////////////////////////////////////////////////////////////////////////////// LaunchedEffect(key1 = processViewModel.jobRunningLastUpdated) { - if(processViewModel.initLoadState is LoadState.Loading && processViewModel.processUUID != null && processViewModel.waitingOnBackend && !processViewModel.cancelling) - { + if (processViewModel.initLoadState is LoadState.Loading && processViewModel.processUUID != null && processViewModel.waitingOnBackend && !processViewModel.cancelling) { delay(1000) processViewModel.checkIn() } @@ -118,9 +114,10 @@ fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifie { LoadStateView( loadStates, - modifier = Modifier.weight(1f), - reloader = processViewModel.retryLabel()?.let { label -> - com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.Reloader(label, { processViewModel.retryPendingStep() }) }, + modifier = Modifier.weight(1f), + reloader = processViewModel.retryLabel()?.let { label -> + com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.Reloader(label, { processViewModel.retryPendingStep() }) + }, loadingText = "Loading Process" ) { @@ -133,21 +130,17 @@ fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifie /////////////////////////////////////////////////////////////////////////////////////////////////////////// key(processViewModel.stepInstanceCounter) { - when (lastStepResult) - { + when (lastStepResult) { is QJobRunning, - is QJobStarted -> - { + is QJobStarted -> { ProcessStepLoading(processViewModel) } - is QJobComplete -> - { + is QJobComplete -> { ProcessFrontendStepView(processViewModel, qInstance) } - is QJobError -> - { + is QJobError -> { Text("Error: ${lastStepResult.userFacingError ?: lastStepResult.error}") } } @@ -155,38 +148,34 @@ fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifie } } - if(LoadState.areAnyErrors(loadStates) || LoadState.areAnyLoading(loadStates)) - { + if (LoadState.areAnyErrors(loadStates) || LoadState.areAnyLoading(loadStates)) { Button(onClick = processViewModel::requestClose, enabled = !processViewModel.cancelling) { Text("Cancel") } } } } -/*************************************************************************** +/******************************************************************************* ** composable that outputs a frontend process step - its components basically - ***************************************************************************/ + *******************************************************************************/ @Composable -fun ProcessFrontendStepView(processViewModel: ProcessViewModel, qInstance: QInstance, modifier: Modifier = Modifier) -{ +fun ProcessFrontendStepView(processViewModel: ProcessViewModel, qInstance: QInstance, modifier: Modifier = Modifier) { val format = FrontendStepFormat.of(processViewModel.activeStep?.format) Column(modifier.fillMaxSize().imePadding()) { Column( modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()) - .padding(if(format.doesScreenGetPadding()) 8.dp else 0.dp) + .padding(if (format.doesScreenGetPadding()) 8.dp else 0.dp) .zIndex(1f) ) { - if(format.isStepLabelDisplayed()) - { - Text(processViewModel.activeStep?.label ?: if(processViewModel.getNoMoreSteps()) "Complete" else "", fontWeight = FontWeight.Bold, fontSize = 20.sp) + if (format.isStepLabelDisplayed()) { + Text(processViewModel.activeStep?.label ?: if (processViewModel.getNoMoreSteps()) "Complete" else "", fontWeight = FontWeight.Bold, fontSize = 20.sp) } processViewModel.activeStep?.components?.forEach() { component -> - when (component.type) - { + when (component.type) { QComponentType.EDIT_FORM -> ProcessComponentEditForm(processViewModel, component) QComponentType.VIEW_FORM -> ProcessComponentViewForm(processViewModel, component) QComponentType.HELP_TEXT -> ProcessComponentHelpText(processViewModel, component) @@ -200,37 +189,40 @@ fun ProcessFrontendStepView(processViewModel: ProcessViewModel, qInstance: QInst processViewModel.unsupportedReason()?.takeUnless { processViewModel.getNoMoreSteps() }?.let { Text(it, color = Colors.ERROR) } processViewModel.submissionError?.let { Text(it, color = Colors.ERROR) } } - if(format.areStandardFooterButtonsDisplayed() || processViewModel.unsupportedReason() != null || processViewModel.getNoMoreSteps()) - { + if (format.areStandardFooterButtonsDisplayed() || processViewModel.unsupportedReason() != null || processViewModel.getNoMoreSteps()) { ProcessBottomButtons(processViewModel) } } } -/*************************************************************************** - ** - ***************************************************************************/ -fun createMediaPlayer(context: Context, resourceId: Int): MediaPlayer? -{ +/******************************************************************************* + *******************************************************************************/ +fun createMediaPlayer(context: Context, resourceId: Int): MediaPlayer? { val player: MediaPlayer? = MediaPlayer.create(context, resourceId) player?.setVolume(1f, 1f) return player } -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -internal fun rememberProcessSound(tone: ProcessTone): () -> Unit -{ +internal fun rememberProcessSound(tone: ProcessTone): () -> Unit { val lifecycleOwner = LocalLifecycleOwner.current val sound = remember(tone) { ProcessSound { val generator = ToneGenerator(AudioManager.STREAM_MUSIC, 50) - object : ProcessSound.Playback - { - override fun start() { generator.startTone(tone.platformType, tone.durationMs) } + object : ProcessSound.Playback { + /******************************************************************************* + ** Delegate optional process audio to the current Android media player without changing process state. + *******************************************************************************/ + override fun start() { + generator.startTone(tone.platformType, tone.durationMs) + } + + /******************************************************************************* + ** Release the current media player when the process sound owner is disposed. + *******************************************************************************/ override fun release() = generator.release() } } @@ -239,8 +231,7 @@ internal fun rememberProcessSound(tone: ProcessTone): () -> Unit { val observer = LifecycleEventObserver() { _, event -> - if(event == Lifecycle.Event.ON_STOP) - { + if (event == Lifecycle.Event.ON_STOP) { sound.release() } } @@ -254,16 +245,16 @@ internal fun rememberProcessSound(tone: ProcessTone): () -> Unit } } return { - if(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) - { + if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { sound.play() } } } -/** Platform-generated feedback avoids redistributing recordings of unknown origin. */ -internal enum class ProcessTone(val platformType: Int, val durationMs: Int) -{ +/******************************************************************************* + ** Platform-generated feedback avoids redistributing recordings of unknown origin. + *******************************************************************************/ +internal enum class ProcessTone(val platformType: Int, val durationMs: Int) { SUBMIT(ToneGenerator.TONE_PROP_ACK, 80), LOADING(ToneGenerator.TONE_PROP_BEEP, 25) } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/PlainText.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/PlainText.kt index 5a6f5e6..aa9c63a 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/PlainText.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/PlainText.kt @@ -3,5 +3,7 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.components import androidx.core.text.HtmlCompat -/** Compatibility fallback when an older server has no semantic plain-text sibling. */ +/******************************************************************************* + ** Compatibility fallback when an older server has no semantic plain-text sibling. + *******************************************************************************/ internal fun plainText(html: String): String = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY).toString().trim() diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentEditForm.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentEditForm.kt index a620fcf..07b5e77 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentEditForm.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentEditForm.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -31,8 +32,19 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -44,54 +56,64 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.SemanticProcess import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.semanticFieldHint import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.semanticInitialValue +/******************************************************************************* + ** Render server-selected editable fields and write changes into the current process values using semantic metadata. + *******************************************************************************/ @Composable -fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFrontendComponent) -{ +fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFrontendComponent) { val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current Column { (component.values["sectionLabel"] as? String)?.let { Text(it) } SemanticProcess.fields(processViewModel.activeStep, component).forEach { field -> var value by remember(processViewModel.stepInstanceCounter, field.name) { - mutableStateOf(semanticInitialValue(field, processViewModel.processValues)) + mutableStateOf(semanticInitialValue(field, processViewModel.processValues)) } val enabled = field.isEditable && !processViewModel.waitingOnBackend && !processViewModel.cancelling && processViewModel.unsupportedReason() == null val error = processViewModel.fieldErrors[field.name] + + /******************************************************************************* + ** Keep the displayed field value and submission map synchronized for the same semantic field. + *******************************************************************************/ fun change(newValue: Any?) { value = newValue processViewModel.processValues[field.name] = newValue } LaunchedEffect(processViewModel.stepInstanceCounter, field.name) { - if(!processViewModel.processValues.containsKey(field.name)) processViewModel.processValues[field.name] = value - if(field.isEditable) processViewModel.registerFormFieldName(field.name) + if (!processViewModel.processValues.containsKey(field.name)) processViewModel.processValues[field.name] = value + if (field.isEditable) processViewModel.registerFormFieldName(field.name) } Column(Modifier.fillMaxWidth().padding(8.dp)) { val options = field.inlinePossibleValueSource - if(!options.isNullOrEmpty()) { + if (!options.isNullOrEmpty()) { var expanded by remember { mutableStateOf(false) } Text(field.label) OutlinedButton(onClick = { expanded = true }, enabled = enabled) { Text(options.firstOrNull { it.id.toString() == value?.toString() }?.label ?: "Choose a value") } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - if(!field.isRequired && field.validation?.required != true) DropdownMenuItem(text = { Text("None") }, onClick = { change(null); expanded = false }) + if (!field.isRequired && field.validation?.required != true) DropdownMenuItem(text = { Text("None") }, onClick = { change(null); expanded = false }) options.forEach { option -> DropdownMenuItem(text = { Text(option.label) }, onClick = { change(option.id); expanded = false }) } } - } else if(field.type == QFieldType.BOOLEAN) { + } else if (field.type == QFieldType.BOOLEAN) { Row { Checkbox(checked = value == true || value == "true", onCheckedChange = { change(it) }, enabled = enabled); Text(field.label) } } else { val multiline = field.type in setOf(QFieldType.TEXT, QFieldType.HTML) - TextField(value = value?.toString().orEmpty(), onValueChange = { change(it) }, - label = { Text(field.label + if(field.isRequired || field.validation?.required == true) " *" else "") }, + TextField( + value = value?.toString().orEmpty(), onValueChange = { change(it) }, + label = { Text(field.label + if (field.isRequired || field.validation?.required == true) " *" else "") }, enabled = enabled, isError = error != null, singleLine = !multiline, - keyboardOptions = KeyboardOptions(imeAction = if(multiline) ImeAction.Default else ImeAction.Done, - keyboardType = if(field.type == QFieldType.PASSWORD) KeyboardType.Password else KeyboardType.Text), + keyboardOptions = KeyboardOptions( + imeAction = if (multiline) ImeAction.Default else ImeAction.Done, + keyboardType = if (field.type == QFieldType.PASSWORD) KeyboardType.Password else KeyboardType.Text + ), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboard?.hide() }), - visualTransformation = if(field.type == QFieldType.PASSWORD) PasswordVisualTransformation() else VisualTransformation.None, - modifier = Modifier.fillMaxWidth()) + visualTransformation = if (field.type == QFieldType.PASSWORD) PasswordVisualTransformation() else VisualTransformation.None, + modifier = Modifier.fillMaxWidth() + ) } - if(error != null) Text(error, color = MaterialTheme.colorScheme.error) + if (error != null) Text(error, color = MaterialTheme.colorScheme.error) semanticFieldHint(field)?.let { Text(it) } field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: plainText(it.content)) } } @@ -99,15 +121,17 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro } } +/******************************************************************************* + ** Render visible server-selected fields read-only, masking password values and stripping display HTML. + *******************************************************************************/ @Composable -fun ProcessComponentViewForm(processViewModel: ProcessViewModel, component: QFrontendComponent) -{ +fun ProcessComponentViewForm(processViewModel: ProcessViewModel, component: QFrontendComponent) { Column { SemanticProcess.fields(processViewModel.activeStep, component).forEach { field -> val value = processViewModel.processValues[field.name] val display = field.inlinePossibleValueSource?.firstOrNull { it.id.toString() == value?.toString() }?.label - ?: if(field.type == QFieldType.PASSWORD && value != null) "••••••" else value?.toString().orEmpty() - Text("${field.label}: ${if(field.type == QFieldType.HTML) plainText(display) else display}", Modifier.padding(8.dp)) + ?: if (field.type == QFieldType.PASSWORD && value != null) "••••••" else value?.toString().orEmpty() + Text("${field.label}: ${if (field.type == QFieldType.HTML) plainText(display) else display}", Modifier.padding(8.dp)) } } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentHTML.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentHTML.kt index 8e45705..86b2149 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentHTML.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentHTML.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -32,24 +33,18 @@ import androidx.compose.ui.unit.dp import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun ProcessComponentHTML(processViewModel: ProcessViewModel, component: QFrontendComponent) +fun ProcessComponentHTML(processViewModel: ProcessViewModel, @Suppress("UNUSED_PARAMETER") component: QFrontendComponent) // Keep the uniform renderer API; this renderer reads HTML from the active process values. { val semanticText = processViewModel.processValues["${processViewModel.activeStep?.name}.text"] as? String val processValue = processViewModel.processValues["${processViewModel.activeStep?.name}.html"] - val html: String = if(processValue is String) - { + val html: String = if (processValue is String) { processValue - } - else if(processValue == null) - { + } else if (processValue == null) { "" - } - else - { + } else { processValue.toString() } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentHelpText.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentHelpText.kt index c5a52e4..413e6ae 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentHelpText.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentHelpText.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -31,13 +32,10 @@ import androidx.compose.ui.unit.dp import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun ProcessComponentHelpText(processViewModel: ProcessViewModel, component: QFrontendComponent) -{ - // todo - if there's preview text, show that first, w/ a toggle to text. +fun ProcessComponentHelpText(processViewModel: ProcessViewModel, component: QFrontendComponent) { Text( (processViewModel.processValues["text"] ?: component.values["text"])?.toString().orEmpty(), color = Color.Gray, diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentProcessSummaryResults.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentProcessSummaryResults.kt index 691a7ec..3beaa21 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentProcessSummaryResults.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentProcessSummaryResults.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -27,13 +28,16 @@ import androidx.compose.runtime.Composable import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel +/******************************************************************************* + ** Render the server's process summary results rather than inferring completion details from client state. + *******************************************************************************/ @Composable -fun ProcessComponentProcessSummaryResults(processViewModel: ProcessViewModel, component: QFrontendComponent) +fun ProcessComponentProcessSummaryResults(processViewModel: ProcessViewModel, @Suppress("UNUSED_PARAMETER") component: QFrontendComponent) // Keep the uniform renderer API; result rows come from authoritative process values. { Column { (processViewModel.processValues["recordCount"] as? Number)?.let { Text("$it records processed") } (processViewModel.processValues["processResults"] as? List<*>)?.forEach { result -> - if(result is Map<*, *>) { + if (result is Map<*, *>) { val status = result["status"]?.toString().orEmpty() val count = (result["count"] as? Number)?.let { "$it " }.orEmpty() val message = result["message"]?.toString().orEmpty() diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentWidget.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentWidget.kt index 35ab376..73c9f58 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentWidget.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/ProcessComponentWidget.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -38,12 +39,11 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBloc import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.SemanticProcess -/*************************************************************************** +/******************************************************************************* ** process-component to render a widget - ***************************************************************************/ + *******************************************************************************/ @Composable -fun ProcessComponentWidget(processViewModel: ProcessViewModel, qInstance: QInstance, component: QFrontendComponent, disableControls: Boolean = false) -{ +fun ProcessComponentWidget(processViewModel: ProcessViewModel, qInstance: QInstance, component: QFrontendComponent, disableControls: Boolean = false) { val state = remember(processViewModel.stepInstanceCounter, component) { determineState(processViewModel, qInstance, component) } // The process owns vertical scrolling so widgets and forms share one viewport. @@ -64,9 +64,9 @@ fun ProcessComponentWidget(processViewModel: ProcessViewModel, qInstance: QInsta } } -/*************************************************************************** +/******************************************************************************* ** container for the state within this widget - ***************************************************************************/ + *******************************************************************************/ data class ProcessComponentWidgetState( val warningStrings: List = mutableListOf(), val headerBlockList: List = mutableListOf(), @@ -75,12 +75,11 @@ data class ProcessComponentWidgetState( val needFullHeight: Boolean = false, ) -/*************************************************************************** +/******************************************************************************* ** function to figure out the (initial, and probably only) state of this ** widget - ***************************************************************************/ -private fun determineState(processViewModel: ProcessViewModel, qInstance: QInstance, component: QFrontendComponent): ProcessComponentWidgetState -{ + *******************************************************************************/ +private fun determineState(processViewModel: ProcessViewModel, qInstance: QInstance, component: QFrontendComponent): ProcessComponentWidgetState { val warningStrings = mutableListOf() val headerBlockList = mutableListOf() val bodyBlockList = mutableListOf() @@ -92,50 +91,36 @@ private fun determineState(processViewModel: ProcessViewModel, qInstance: QInsta ////////////////////////////////////////////////////////////////////////////////////////////// val widgetBlockList = mutableListOf() - if(component.values["isAdHocWidget"] == true) - { + if (component.values["isAdHocWidget"] == true) { val blocks = component.values["blocks"] - if(blocks is List<*>) - { + if (blocks is List<*>) { blocks.forEach() { block -> val widgetBlock = WidgetBlock.fromMap(block) - if(widgetBlock != null) - { + if (widgetBlock != null) { widgetBlockList.add(widgetBlock) } } - } - else - { + } else { warningStrings.add("Widget blocks was not an expected type.") } - } - else if(component.values["widgetName"] != null) - { + } else if (component.values["widgetName"] != null) { val widgetName = "${component.values["widgetName"]}" val widgetMetaData = qInstance.widgets?.get(widgetName) - if(widgetMetaData == null) - { + if (widgetMetaData == null) { warningStrings.add("Undefined widget: ${widgetName}") - } - else - { - if(widgetMetaData.type == "composite") - { + } else { + if (widgetMetaData.type == "composite") { val widgetData = processViewModel.processValues[widgetName] var foundBlocks = false - if(widgetData is Map<*, *>) - { + if (widgetData is Map<*, *>) { val blocks = widgetData["subBlocks"] - if(blocks is List<*>) - { + if (blocks is List<*>) { blocks.forEach() { block -> val widgetBlock = WidgetBlock.fromMap(block) - if(widgetBlock != null) - { + if (widgetBlock != null) { widgetBlockList.add(widgetBlock) foundBlocks = true } @@ -143,91 +128,57 @@ private fun determineState(processViewModel: ProcessViewModel, qInstance: QInsta } } - if(!foundBlocks) - { + if (!foundBlocks) { warningStrings.add("Could not find widget block data in process.") } - } - else - { + } else { warningStrings.add("Not-yet implemented widget type: ${widgetMetaData.type}") } } - } - else - { + } else { warningStrings.add("Missing required values in a wiget component") } ///////////////////////////////////////////////////////////////////////////////////////// // split into body & footer lists // - // todo no no, this should be done based on some attribute in the widget/blocks/etc... // ///////////////////////////////////////////////////////////////////////////////////////// evaluateWidgetBlocks(widgetBlockList, processViewModel.processValues) bodyBlockList.addAll(widgetBlockList) - /* - if(bodyBlockList.size > 2) - { - headerBlockList.add(bodyBlockList.removeAt(0)) - footerBlockList.add(bodyBlockList.removeAt(bodyBlockList.size - 1)) - needFullHeight = true - } - */ return ProcessComponentWidgetState(warningStrings, headerBlockList, bodyBlockList, footerBlockList, needFullHeight) } -/*************************************************************************** +/******************************************************************************* ** applies "conditional" logic to blocks; interpolate values too. - ***************************************************************************/ -fun evaluateWidgetBlocks(widgetBlockList: MutableList, processValues: MutableMap) -{ + *******************************************************************************/ +fun evaluateWidgetBlocks(widgetBlockList: MutableList, processValues: MutableMap) { val iterator = widgetBlockList.iterator() - while(iterator.hasNext()) - { + while (iterator.hasNext()) { val widgetBlock = iterator.next() //////////////////////////////////////////////////////////////////// // if the block has a conditional, evaluate, and remove if untrue // //////////////////////////////////////////////////////////////////// - if(!SemanticProcess.widgetVisible(widgetBlock.conditional, processValues)) - { + if (!SemanticProcess.widgetVisible(widgetBlock.conditional, processValues)) { iterator.remove() continue } - if(widgetBlock.blockType == "COMPOSITE") - { + if (widgetBlock.blockType == "COMPOSITE") { ///////////////////////////////////////// // make recursive calls for composites // ///////////////////////////////////////// val mutableSubBlocks = widgetBlock.subBlocks.toMutableList() evaluateWidgetBlocks(mutableSubBlocks, processValues) widgetBlock.subBlocks = mutableSubBlocks - } - else if(widgetBlock.blockType == "INPUT_FIELD") - { + } else if (widgetBlock.blockType == "INPUT_FIELD") { ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // for input fields, put the process's value for the field-name into the block's values object as '.value' // - // todo QFMD was doing something here, but we didn't do it in qqq-android yet... // ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - // val fieldMetaData = widgetBlock.values["fieldMetaData"] - // if(fieldMetaData != null && fieldMetaData is Map<*, *>) - // { - // val fieldName = fieldMetaData["name"] - // if(processValues.containsKey(fieldName)) - // { - // widgetBlock.values["value"] = processValues[fieldName] - // } - // } - } - else if(widgetBlock.blockType == "TEXT") - { + } else if (widgetBlock.blockType == "TEXT") { var text = widgetBlock.values["text"] - if(text != null && text is String) - { - for(entry in processValues.entries) - { + if (text != null && text is String) { + for (entry in processValues.entries) { text = (text as String).replace('$' + "{" + entry.key + "}", "${processValues[entry.key]}") } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/UnsupportedProcessComponent.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/UnsupportedProcessComponent.kt index d7e2d05..6de9937 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/UnsupportedProcessComponent.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/components/UnsupportedProcessComponent.kt @@ -1,3 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.components import androidx.compose.foundation.border @@ -13,11 +34,10 @@ import androidx.compose.ui.unit.dp import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun UnsupportedProcessComponent(processViewModel: ProcessViewModel, component: QFrontendComponent) +fun UnsupportedProcessComponent(@Suppress("UNUSED_PARAMETER") processViewModel: ProcessViewModel, component: QFrontendComponent) // Keep the uniform renderer API; unsupported metadata is displayed without touching process state. { Text( "Unsupported component type: ${component.type}", @@ -29,4 +49,4 @@ fun UnsupportedProcessComponent(processViewModel: ProcessViewModel, component: Q .border(1.dp, Color.Black, RoundedCornerShape(8.dp)) .padding(12.dp) ) -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/BlockStyleUtils.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/BlockStyleUtils.kt index 0509e32..6381ee4 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/BlockStyleUtils.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/BlockStyleUtils.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -29,100 +30,77 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -/*************************************************************************** +/******************************************************************************* ** utility functions for working with styles in blocks - ***************************************************************************/ -object BlockStyleUtils -{ + *******************************************************************************/ +object BlockStyleUtils { - /*************************************************************************** + /******************************************************************************* ** read a padding map, from a style map, reading top/bottom/left/right out ** of it. only (optional) numeric values supported today. - ***************************************************************************/ - fun getPaddingValuesFromStyleMap(map: Map<*, *>): PaddingValues - { + *******************************************************************************/ + fun getPaddingValuesFromStyleMap(map: Map<*, *>): PaddingValues { val paddingStyle = map["padding"] - return if(paddingStyle is Map<*, *>) - { + return if (paddingStyle is Map<*, *>) { PaddingValues( top = getIntFromMap(paddingStyle, "top", 0).dp, bottom = getIntFromMap(paddingStyle, "bottom", 0).dp, start = getIntFromMap(paddingStyle, "left", 0).dp, end = getIntFromMap(paddingStyle, "right", 0).dp ) - } - else - { + } else { PaddingValues() } } - /*************************************************************************** + /******************************************************************************* ** read width & height from a style map. only (optional) numeric values ** supported today. - ***************************************************************************/ - fun getSizeFromStyleMap(map: Map<*, *>): DpSize - { + *******************************************************************************/ + fun getSizeFromStyleMap(map: Map<*, *>): DpSize { val width = getOptionalIntFromMap(map, "width") val height = getOptionalIntFromMap(map, "height") - return if(width != null && height != null) - { + return if (width != null && height != null) { DpSize(width.dp, height.dp) - } - else if(width != null) - { + } else if (width != null) { DpSize(width.dp, Dp.Unspecified) - } - else if(height != null) - { + } else if (height != null) { DpSize(Dp.Unspecified, height.dp) - } - else - { + } else { DpSize(Dp.Unspecified, Dp.Unspecified) } } - /*************************************************************************** + /******************************************************************************* ** Get a Color from a string. Some named colors are supported, as well as ** html-style RGB/RGBA, with optional # prefix. - ***************************************************************************/ - fun getColorFromString(input: String?): Color? - { - return when (val string = "${input}".toUpperCase(Locale.current)) - { + *******************************************************************************/ + fun getColorFromString(input: String?): Color? { + return when (val string = "${input}".toUpperCase(Locale.current)) { "SUCCESS" -> Colors.SUCCESS "WARNING" -> Colors.WARNING "ERROR" -> Colors.ERROR "INFO" -> Colors.INFO "MUTED" -> Colors.MUTED - else -> - { + else -> { var hexString = string val sixToEightHexDigits = "[0-9A-F]{6,8}" - if(hexString.matches(Regex("^#${sixToEightHexDigits}$"))) - { + if (hexString.matches(Regex("^#${sixToEightHexDigits}$"))) { hexString = hexString.substring(1) } - if(hexString.matches(Regex("^${sixToEightHexDigits}$"))) - { + if (hexString.matches(Regex("^${sixToEightHexDigits}$"))) { val r = Integer.parseInt(hexString, 0, 2, 16) val g = Integer.parseInt(hexString, 2, 4, 16) val b = Integer.parseInt(hexString, 4, 6, 16) - val a = try - { + val a = try { Integer.parseInt(hexString, 6, 8, 16) - } - catch(e: Exception) - { + } catch (e: Exception) { 255 } Color(r, g, b, a) - } - else - { + } else { null } } @@ -130,70 +108,49 @@ object BlockStyleUtils } - /*************************************************************************** - ** - ***************************************************************************/ - fun getOptionalIntFromMap(map: Map<*, *>, key: String): Int? - { + /******************************************************************************* + *******************************************************************************/ + fun getOptionalIntFromMap(map: Map<*, *>, key: String): Int? { val valueString = "${map[key] ?: ""}" - return if(valueString == "") - { + return if (valueString == "") { null - } - else - { - try - { + } else { + try { Integer.parseInt(valueString) - } - catch(e: Exception) - { + } catch (e: Exception) { null } } } - /*************************************************************************** - ** - ***************************************************************************/ - fun getOptionalStringFromMap(map: Map<*, *>, key: String): String? - { + /******************************************************************************* + *******************************************************************************/ + fun getOptionalStringFromMap(map: Map<*, *>, key: String): String? { val valueString = "${map[key] ?: ""}" - return if(valueString == "") - { + return if (valueString == "") { null - } - else - { + } else { valueString } } - /*************************************************************************** - ** - ***************************************************************************/ - fun getIntFromMap(map: Map<*, *>, key: String, default: Int): Int - { + /******************************************************************************* + *******************************************************************************/ + fun getIntFromMap(map: Map<*, *>, key: String, default: Int): Int { val valueString = "${map[key] ?: ""}" - return if(valueString == "") - { + return if (valueString == "") { default - } - else - { - try - { + } else { + try { Integer.parseInt(valueString) - } - catch(e: Exception) - { + } catch (e: Exception) { default } } } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Colors.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Colors.kt index b280460..75b1311 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Colors.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Colors.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -23,15 +24,14 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.utils import androidx.compose.ui.graphics.Color -/*************************************************************************** +/******************************************************************************* ** define some standard colors - ***************************************************************************/ -object Colors -{ + *******************************************************************************/ +object Colors { val SUCCESS = Color(0x2B, 0xA3, 0x3F) val WARNING = Color(0xCC, 0x84, 0x02) val ERROR = Color(0xFB, 0x41, 0x41) val INFO = Color(0x45, 0x8C, 0xFF) val MUTED = Color(0x7b, 0x80, 0x9a) -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/DrawableUtils.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/DrawableUtils.kt index f1bbc34..7d42135 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/DrawableUtils.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/DrawableUtils.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,37 +25,31 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.utils import android.annotation.SuppressLint import android.content.Context -/*************************************************************************** +/******************************************************************************* ** utilities for working with drawable resources - ***************************************************************************/ -object DrawableUtils -{ + *******************************************************************************/ +object DrawableUtils { - /*************************************************************************** + /******************************************************************************* ** given a name (e.g., a material icon name), get the corresponding ** resource identifier. Null (instead of 0...) if it's not found. - ***************************************************************************/ + *******************************************************************************/ @SuppressLint("DiscouragedApi") // for getIdentifier... - fun loadDrawableId(context: Context, name: String?): Int? - { - if(name == null) - { + fun loadDrawableId(context: Context, name: String?): Int? { + if (name == null) { return (null) } - try - { + try { val rs = context.resources.getIdentifier( name, "drawable", context.packageName ) - return if(rs == 0) null else rs - } - catch(e: Exception) - { + return if (rs == 0) null else rs + } catch (e: Exception) { return null } } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Dropdown.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Dropdown.kt index 493ae7e..b80e7fb 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Dropdown.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Dropdown.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -48,19 +49,17 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun Dropdown(value: T, options: List, labelExtractor: (t: T) -> String, onClick: (t: T) -> Unit, modifier: Modifier = Modifier) -{ +fun Dropdown(value: T, options: List, labelExtractor: (t: T) -> String, onClick: (t: T) -> Unit, modifier: Modifier = Modifier) { val dropdownExpanded = remember { mutableStateOf(false) } val textFieldSize = remember { mutableStateOf(IntSize.Zero) } val selectedValue = remember { mutableStateOf(value) } - val icon = if(dropdownExpanded.value) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown + val icon = if (dropdownExpanded.value) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown Box( - Modifier + modifier .fillMaxWidth() .clickable { dropdownExpanded.value = !dropdownExpanded.value } ) { @@ -74,7 +73,7 @@ fun Dropdown(value: T, options: List, labelExtractor: (t: T) -> String, o ) { Text(text = labelExtractor.invoke(selectedValue.value)) - Icon(icon, if(dropdownExpanded.value) "close" else "open") + Icon(icon, if (dropdownExpanded.value) "close" else "open") } DropdownMenu( @@ -87,14 +86,14 @@ fun Dropdown(value: T, options: List, labelExtractor: (t: T) -> String, o { option -> DropdownMenuItem( onClick = - { - selectedValue.value = option - dropdownExpanded.value = false - onClick.invoke(option) - }, + { + selectedValue.value = option + dropdownExpanded.value = false + onClick.invoke(option) + }, text = { Text(text = labelExtractor.invoke(option)) } ) } } } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/DynamicIcon.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/DynamicIcon.kt index 1aa3806..4ef8bef 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/DynamicIcon.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/DynamicIcon.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -32,19 +33,18 @@ import androidx.compose.ui.res.painterResource import com.kingsrook.qqq.frontend.android.core.model.metadata.QIcon import kotlinx.serialization.json.Json -/*************************************************************************** +/******************************************************************************* ** Render an icon - given a QIcon model, a defaultResourceId to use if it ** isn't found, and so-on. - ***************************************************************************/ + *******************************************************************************/ @Composable -fun DynamicIcon(icon: QIcon?, defaultResourceId: Int?, modifier: Modifier = Modifier, contentDescription: String? = null, tint: Color = Color.Black) -{ +fun DynamicIcon(icon: QIcon?, defaultResourceId: Int?, modifier: Modifier = Modifier, contentDescription: String? = null, tint: Color = Color.Black) { val context = LocalContext.current val drawableId = remember(icon?.semanticName, icon?.name) { DrawableUtils.loadDrawableId(context, icon?.semanticName) ?: DrawableUtils.loadDrawableId(context, icon?.name) } - val actualId = if(drawableId != null) drawableId else if(defaultResourceId != null) defaultResourceId else return + val actualId = if (drawableId != null) drawableId else if (defaultResourceId != null) defaultResourceId else return Icon( painter = painterResource(id = actualId), @@ -55,24 +55,18 @@ fun DynamicIcon(icon: QIcon?, defaultResourceId: Int?, modifier: Modifier = Modi } -/*************************************************************************** +/******************************************************************************* ** Convert an Any (could be a map, or a json string) into a QIcon - ***************************************************************************/ -fun makeIcon(value: Any?): QIcon? -{ - if(value is Map<*, *>) - { + *******************************************************************************/ +fun makeIcon(value: Any?): QIcon? { + if (value is Map<*, *>) { return (QIcon(value["name"]?.toString(), value["path"]?.toString(), value["color"]?.toString(), semanticName = value["semanticName"]?.toString())) } - if(value is String) - { - try - { + if (value is String) { + try { return Json { ignoreUnknownKeys = true }.decodeFromString(value) - } - catch(e: Exception) - { + } catch (e: Exception) { // Unknown icon metadata is decorative; retain the caller's fallback. } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/LoadStateView.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/LoadStateView.kt index d5c2f75..9fa22c5 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/LoadStateView.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/LoadStateView.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -44,9 +45,9 @@ import com.kingsrook.qqq.frontend.android.mobileapp.R import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullScreenLoading import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState -/*************************************************************************** +/******************************************************************************* ** Composable that - ***************************************************************************/ + *******************************************************************************/ @Composable fun LoadStateView( loadStates: List>, @@ -55,11 +56,9 @@ fun LoadStateView( loadingText: String = "Loading...", errorHeadline: String = "An Error Has Occurred", content: @Composable ColumnScope.() -> Unit -) -{ +) { - if(LoadState.areAnyErrors(loadStates)) - { + if (LoadState.areAnyErrors(loadStates)) { /////////////////////////////////////////////////////// // show errors, with reload button, if one was given // /////////////////////////////////////////////////////// @@ -90,8 +89,7 @@ fun LoadStateView( } } - if(reloader != null) - { + if (reloader != null) { item() { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) @@ -105,16 +103,12 @@ fun LoadStateView( } } - } - else if(LoadState.areAnyLoading(loadStates)) - { + } else if (LoadState.areAnyLoading(loadStates)) { ////////////////// // show loading // ////////////////// FullScreenLoading(loadingText, modifier) - } - else - { + } else { /////////////////////// // show the content! // /////////////////////// diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Reloader.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Reloader.kt index a5e5f45..aae51b2 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Reloader.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/Reloader.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -20,10 +21,9 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.utils -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ data class Reloader( val buttonLabel: String, val callback: () -> Unit -) \ No newline at end of file +) diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/AudioWidgetBlock.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/AudioWidgetBlock.kt index 9e613c0..fef38db 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/AudioWidgetBlock.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/AudioWidgetBlock.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -35,27 +36,25 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun AudioWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier) -{ +fun AudioWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier) { val widgetBlock = params.widgetBlock val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current var mediaPlayer: MediaPlayer? = null val path = widgetBlock.values["path"] - if(path is String) - { + if (path is String) { val uri = runCatching { params.qViewModel?.getURI(path) }.getOrNull() val context = LocalContext.current - if(uri != null) - { - fun doPlay() - { + if (uri != null) { + /******************************************************************************* + ** Start the current widget audio resource only through its existing playback control. + *******************************************************************************/ + fun doPlay() { mediaPlayer = createMediaPlayer(context, uri) mediaPlayer?.start() } @@ -71,24 +70,26 @@ fun AudioWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifie /////////////////////////////////////////////////////////////// val observer = LifecycleEventObserver() { _, event -> - if(event == Lifecycle.Event.ON_START) - { + if (event == Lifecycle.Event.ON_START) { ////////////////////////////////////////////////////////////////////////////////////////////// // go through the actionCallback - to try to manage making sure we only play the audio once // // (since we'll assume that the callback runs in an object which doesn't get re-created if // // the device orientation changes -- without this (e.g., if we just played the audio here), // // then we re-played it upon screen rotation - which wasn't good. // ////////////////////////////////////////////////////////////////////////////////////////////// - params.actionCallback?.invoke(mapOf( - "registerControlCallbackName" to "AudioWidgetBlock", - "registerControlCallbackFunction" to ::doPlay)) + params.actionCallback?.invoke( + mapOf( + "registerControlCallbackName" to "AudioWidgetBlock", + "registerControlCallbackFunction" to ::doPlay + ) + ) - params.actionCallback?.invoke(mapOf( - "controlCode" to "invokeControlCallbackOnlyOnce:AudioWidgetBlock" - )) - } - else if(event == Lifecycle.Event.ON_STOP) - { + params.actionCallback?.invoke( + mapOf( + "controlCode" to "invokeControlCallbackOnlyOnce:AudioWidgetBlock" + ) + ) + } else if (event == Lifecycle.Event.ON_STOP) { mediaPlayer?.release() mediaPlayer = null } @@ -109,24 +110,19 @@ fun AudioWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifie mediaPlayer = null } } + } else { + Text("No uri for audio...", modifier = modifier) } - else - { - Text("No uri for audio...") - } - } - else - { - Text("No path for audio...") + } else { + Text("No path for audio...", modifier = modifier) } } -/*************************************************************************** +/******************************************************************************* ** Create a media play for a specific URI - ***************************************************************************/ -fun createMediaPlayer(context: Context, uri: String): MediaPlayer? -{ + *******************************************************************************/ +fun createMediaPlayer(context: Context, uri: String): MediaPlayer? { val player: MediaPlayer? = MediaPlayer.create(context, Uri.parse(uri)) player?.setVolume(1f, 1f) return player diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/ButtonWidgetBlock.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/ButtonWidgetBlock.kt index b3bbbc0..ed50458 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/ButtonWidgetBlock.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/ButtonWidgetBlock.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -42,12 +43,10 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.BlockStyleUtils import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.DynamicIcon import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.makeIcon -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun ButtonWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier, disableControls: Boolean = false) -{ +fun ButtonWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier, disableControls: Boolean = false) { val widgetBlock = params.widgetBlock val actionCallback = params.actionCallback @@ -56,41 +55,35 @@ fun ButtonWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifi val startIcon: QIcon? = makeIcon(widgetBlock.values["startIcon"]) val endIcon: QIcon? = makeIcon(widgetBlock.values["endIcon"]) - /*************************************************************************** + /******************************************************************************* ** handle the button being clicked - basically, just go to the actionCallback. - ***************************************************************************/ - fun onClick() - { + *******************************************************************************/ + fun onClick() { val values = mutableMapOf("widgetBlock" to widgetBlock) - if(actionCallback != null) - { - if(widgetBlock.values["actionCode"] != null || widgetBlock.values["controlCode"] != null) - { + if (actionCallback != null) { + if (widgetBlock.values["actionCode"] != null || widgetBlock.values["controlCode"] != null) { actionCallback(values) } } } - val textColor = if(format == "outlined" || format == "text") color else Color.White - val bgColor = if(format == "outlined") Color.White else color + val textColor = if (format == "outlined" || format == "text") color else Color.White + val bgColor = if (format == "outlined") Color.White else color - /*************************************************************************** + /******************************************************************************* ** row of text , to display inside the button - ***************************************************************************/ + *******************************************************************************/ @Composable - fun ButtonContent() - { + fun ButtonContent() { Row(verticalAlignment = Alignment.CenterVertically) { - if(startIcon != null) - { + if (startIcon != null) { DynamicIcon(startIcon, null, tint = textColor, modifier = Modifier.padding(end = 4.dp)) } Text("${widgetBlock.values["label"] ?: widgetBlock.values["actionCode"] ?: "Button"}", color = textColor) - if(endIcon != null) - { + if (endIcon != null) { DynamicIcon(endIcon, null, tint = textColor, modifier = Modifier.padding(start = 4.dp)) } } @@ -101,10 +94,8 @@ fun ButtonWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifi //////////////////////////////////////////////////////// // choose different button composable based on format // //////////////////////////////////////////////////////// - when (format) - { - "outlined" -> - { + when (format) { + "outlined" -> { OutlinedButton( onClick = ::onClick, modifier = modifier, @@ -117,8 +108,7 @@ fun ButtonWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifi } } - "filled" -> - { + "filled" -> { FilledTonalButton( onClick = ::onClick, modifier = modifier, @@ -131,16 +121,14 @@ fun ButtonWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifi } } - "text" -> - { + "text" -> { TextButton(onClick = ::onClick, modifier = modifier, enabled = !disableControls) { ButtonContent() } } - else -> - { + else -> { Button(onClick = ::onClick, modifier = modifier, shape = shape, enabled = !disableControls) { ButtonContent() @@ -148,4 +136,4 @@ fun ButtonWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifi } } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/CompositeWidgetBlock.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/CompositeWidgetBlock.kt index ef0e716..ddedcad 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/CompositeWidgetBlock.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/CompositeWidgetBlock.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -46,51 +47,45 @@ import androidx.compose.ui.window.DialogProperties import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.BlockStyleUtils -/*************************************************************************** +/******************************************************************************* ** render a block of type "Composite" - which is - a block that contains ** child (sub) blocks. - ***************************************************************************/ + *******************************************************************************/ @Composable -fun CompositeWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier, disableControls: Boolean = false) -{ +fun CompositeWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier, disableControls: Boolean = false) { val widgetBlock = params.widgetBlock - if(widgetBlock.modalMode == null) + Box(modifier = modifier) { - ContentsInLayout(params, disableControls) - } - else - { - when (widgetBlock.modalMode) - { - "MODAL" -> - { - Modal(params, disableControls) + if (widgetBlock.modalMode == null) { + ContentsInLayout(params, disableControls) + } else { + when (widgetBlock.modalMode) { + "MODAL" -> { + Modal(params, disableControls) + } } } } } -/*************************************************************************** +/******************************************************************************* ** render the list of sub-blocks - ***************************************************************************/ + *******************************************************************************/ @Composable -fun BlockList(params: WidgetBlockParameters, subBlocks: List, disableControls: Boolean = false) -{ - for(subBlock in subBlocks) - { +fun BlockList(params: WidgetBlockParameters, subBlocks: List, disableControls: Boolean = false) { + for (subBlock in subBlocks) { val subParams = WidgetBlockParameters(subBlock, params.actionCallback, params.qViewModel, params.values) WidgetBlockComposable(subParams, Modifier.padding(4.dp), disableControls) } } -/*************************************************************************** +/******************************************************************************* ** apply layout-specific formatting, to wrap a block-list - ***************************************************************************/ + *******************************************************************************/ @OptIn(ExperimentalLayoutApi::class) // for FlowRow @Composable -fun ContentsInLayout(params: WidgetBlockParameters, disableControls: Boolean = false) -{ +fun ContentsInLayout(params: WidgetBlockParameters, disableControls: Boolean = false) { val widgetBlock = params.widgetBlock ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -99,16 +94,14 @@ fun ContentsInLayout(params: WidgetBlockParameters, disableControls: Boolean = f var boxModifier = Modifier.padding(0.dp) val backgroundColor = BlockStyleUtils.getColorFromString(BlockStyleUtils.getOptionalStringFromMap(widgetBlock.styles, "backgroundColor")) - if(backgroundColor != null) - { + if (backgroundColor != null) { boxModifier = boxModifier.background(backgroundColor, RectangleShape) } boxModifier = boxModifier.padding(BlockStyleUtils.getPaddingValuesFromStyleMap(widgetBlock.styles)) // TABLE_SUB_ROW_DETAILS, BADGES_WRAPPER - when (widgetBlock.layout) - { + when (widgetBlock.layout) { "FLEX_ROW_CENTER" -> FlowRow(horizontalArrangement = Arrangement.Center, modifier = boxModifier.fillMaxWidth()) { BlockList(params, widgetBlock.subBlocks, disableControls) @@ -141,23 +134,19 @@ fun ContentsInLayout(params: WidgetBlockParameters, disableControls: Boolean = f } } -/*************************************************************************** +/******************************************************************************* ** if the composable is modal, wrap it accordingly - ***************************************************************************/ + *******************************************************************************/ @Composable -fun Modal(params: WidgetBlockParameters, disableControls: Boolean = false) -{ +fun Modal(params: WidgetBlockParameters, disableControls: Boolean = false) { val isModalShown = remember { mutableStateOf(params.values[params.widgetBlock.blockId] == true) } - /*************************************************************************** + /******************************************************************************* ** define a callback function that the view-model can call, to tell us that ** the modal's visible state should change - ***************************************************************************/ - fun controlCallback() - { + *******************************************************************************/ + fun controlCallback() { //////////////////////////////////////////////////////// - // todo - is toggle always appropriate here? // - // if not, i suppose, callback needs to take a map... // //////////////////////////////////////////////////////// isModalShown.value = !isModalShown.value } @@ -177,18 +166,17 @@ fun Modal(params: WidgetBlockParameters, disableControls: Boolean = false) key(params.values[params.widgetBlock.blockId]) { - if(isModalShown.value) - { + if (isModalShown.value) { Dialog( onDismissRequest = - { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // go through the action-callback - so it can internally adjust whatever it needs to track if we are visible // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - val eventValues = mapOf("controlCode" to "hideModal:${params.widgetBlock.blockId}") - params.actionCallback?.invoke(eventValues) - isModalShown.value = false - }, + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // go through the action-callback - so it can internally adjust whatever it needs to track if we are visible // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + val eventValues = mapOf("controlCode" to "hideModal:${params.widgetBlock.blockId}") + params.actionCallback?.invoke(eventValues) + isModalShown.value = false + }, //////////////////////////////////////////////////////// // this line lets us go full-width (esp on landscape) // @@ -214,4 +202,4 @@ fun Modal(params: WidgetBlockParameters, disableControls: Boolean = false) } } } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/DividerWidgetBlock.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/DividerWidgetBlock.kt index 0ac3e81..f7caf77 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/DividerWidgetBlock.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/DividerWidgetBlock.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -26,11 +27,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun DividerWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier) +fun DividerWidgetBlock(@Suppress("UNUSED_PARAMETER") params: WidgetBlockParameters, modifier: Modifier = Modifier) // Keep the common widget renderer API; a divider consumes no server values. { - HorizontalDivider() + HorizontalDivider(modifier = modifier) } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/ImageWidgetBlock.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/ImageWidgetBlock.kt index d88af25..7cd88f4 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/ImageWidgetBlock.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/ImageWidgetBlock.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -39,12 +40,10 @@ import coil3.svg.SvgDecoder import com.kingsrook.qqq.frontend.android.mobileapp.R import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.BlockStyleUtils -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun ImageWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier) -{ +fun ImageWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier) { val widgetBlock = params.widgetBlock val qViewModel = params.qViewModel @@ -56,7 +55,7 @@ fun ImageWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifie DisposableEffect(imageLoader) { onDispose { imageLoader.shutdown() } } val imageModifier: Modifier = Modifier.size(BlockStyleUtils.getSizeFromStyleMap(widgetBlock.styles)) - val boxModifier = Modifier.padding(BlockStyleUtils.getPaddingValuesFromStyleMap(widgetBlock.styles)) + val boxModifier = modifier.padding(BlockStyleUtils.getPaddingValuesFromStyleMap(widgetBlock.styles)) Box(modifier = boxModifier) { diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/InputFieldWidgetBlock.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/InputFieldWidgetBlock.kt index 8d4064b..e9d2d78 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/InputFieldWidgetBlock.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/InputFieldWidgetBlock.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -51,17 +52,17 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.semanticInitialVal import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.semanticFieldHint import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.validateSemanticField -/** A typed native input: autofocus changes focus only and never reinterprets field data as an action. */ +/******************************************************************************* + ** A typed native input: autofocus changes focus only and never reinterprets field data as an action. + *******************************************************************************/ @Composable -fun InputFieldWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier, disableControls: Boolean = false) -{ +fun InputFieldWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier, disableControls: Boolean = false) { val field = SemanticProcess.inputField(params.widgetBlock.values) - if(field == null || (!field.isHidden && !SemanticProcess.supportedWidgetInput(field))) - { + if (field == null || (!field.isHidden && !SemanticProcess.supportedWidgetInput(field))) { Text("This field requires an input control unavailable in this client.", color = Colors.ERROR) return } - if(field.isHidden) return + if (field.isHidden) return var text by remember(field.name) { mutableStateOf(semanticInitialValue(field, params.values)?.toString().orEmpty()) } var error by remember(field.name) { mutableStateOf(null) } val enabled = field.isEditable && !disableControls @@ -69,20 +70,20 @@ fun InputFieldWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Mo val focusRequester = remember { FocusRequester() } LaunchedEffect(field.name) { - if(field.isEditable) - { - if(!params.values.containsKey(field.name)) params.values[field.name] = semanticInitialValue(field, params.values) + if (field.isEditable) { + if (!params.values.containsKey(field.name)) params.values[field.name] = semanticInitialValue(field, params.values) params.qViewModel?.activeProcessViewModel?.registerFormFieldName(field.name) - if(params.widgetBlock.values["autoFocus"] == true) focusRequester.requestFocus() + if (params.widgetBlock.values["autoFocus"] == true) focusRequester.requestFocus() } } - /** The process submission boundary also validates/converts this declared field. */ - fun submit(value: String) - { - if(!enabled) return + /******************************************************************************* + ** The process submission boundary also validates/converts this declared field. + *******************************************************************************/ + fun submit(value: String) { + if (!enabled) return error = validateSemanticField(field, value) - if(error == null) params.actionCallback?.invoke(mapOf(field.name to value)) + if (error == null) params.actionCallback?.invoke(mapOf(field.name to value)) } Column(modifier) @@ -93,22 +94,20 @@ fun InputFieldWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Mo isError = error != null, onValueChange = { value -> val submitted = submitOnEnter && value.endsWith('\n') - text = if(submitted) value.dropLast(1) else value + text = if (submitted) value.dropLast(1) else value params.values[field.name] = text - if(submitted) submit(text) + if (submitted) submit(text) }, label = { Text(field.label) }, singleLine = true, - visualTransformation = if(field.type == QFieldType.PASSWORD) PasswordVisualTransformation() else VisualTransformation.None, - keyboardOptions = KeyboardOptions(imeAction = if(submitOnEnter) ImeAction.Done else ImeAction.Default), - keyboardActions = KeyboardActions(onDone = { if(submitOnEnter) submit(text) }), + visualTransformation = if (field.type == QFieldType.PASSWORD) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions(imeAction = if (submitOnEnter) ImeAction.Done else ImeAction.Default), + keyboardActions = KeyboardActions(onDone = { if (submitOnEnter) submit(text) }), modifier = Modifier.fillMaxWidth().focusRequester(focusRequester).onPreviewKeyEvent { event -> - if(submitOnEnter && event.key == Key.Enter) - { - if(event.type == KeyEventType.KeyUp) submit(text) + if (submitOnEnter && event.key == Key.Enter) { + if (event.type == KeyEventType.KeyUp) submit(text) true - } - else false + } else false } ) error?.let { Text(it, color = Colors.ERROR) } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/TextWidgetBlock.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/TextWidgetBlock.kt index 1de1877..4bb8487 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/TextWidgetBlock.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/TextWidgetBlock.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -45,18 +46,15 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.BlockStyleUtils import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.DynamicIcon import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.makeIcon -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ @Composable -fun TextWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier) -{ +fun TextWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier) { val widgetBlock = params.widgetBlock val color = BlockStyleUtils.getColorFromString("${widgetBlock.styles["color"]}") ?: Color.Black - val weight: FontWeight = when (widgetBlock.styles["weight"]) - { + val weight: FontWeight = when (widgetBlock.styles["weight"]) { "bold" -> FontWeight.Bold "semibold" -> FontWeight.SemiBold "black" -> FontWeight.Black @@ -76,22 +74,17 @@ fun TextWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier else -> FontWeight.Normal } - val size = when (val blockStyleSize = "${widgetBlock.styles["size"]}") - { + val size = when (val blockStyleSize = "${widgetBlock.styles["size"]}") { "largest" -> Typography().displayMedium.fontSize // 45 "headline" -> Typography().headlineMedium.fontSize // 28 "title" -> Typography().titleLarge.fontSize // 22 "body" -> Typography().bodyLarge.fontSize // 16 "smallest" -> Typography().labelMedium.fontSize // 12 - else -> - { + else -> { val isNumeric = blockStyleSize.all { char -> char.isDigit() } - if(isNumeric) - { + if (isNumeric) { Integer.parseInt(blockStyleSize).sp - } - else - { + } else { Typography().bodyLarge.fontSize } } @@ -112,12 +105,9 @@ fun TextWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier var textPadding = 0.dp var horizontalArrangement = Arrangement.Start - if(widgetBlock.styles["format"] == "alert") - { + if (widgetBlock.styles["format"] == "alert") { textPadding = 8.dp - } - else if(widgetBlock.styles["format"] == "banner") - { + } else if (widgetBlock.styles["format"] == "banner") { horizontalArrangement = Arrangement.Center textPadding = 8.dp } @@ -125,20 +115,20 @@ fun TextWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier ///////////////////////////////////////////// // build the stuff that'll go inside a box // ///////////////////////////////////////////// + /******************************************************************************* + ** Apply the text block's declared presentation while keeping widget rendering independent of process navigation. + *******************************************************************************/ @Composable - fun TextComposable() - { - Row(horizontalArrangement = horizontalArrangement, modifier = Modifier.padding(textPadding)) + fun TextComposable(textModifier: Modifier = Modifier) { + Row(horizontalArrangement = horizontalArrangement, modifier = textModifier.padding(textPadding)) { - if(startIcon != null) - { + if (startIcon != null) { DynamicIcon(startIcon, null, tint = color, modifier = Modifier.padding(end = 4.dp)) } Text(text, color = color, fontWeight = weight, fontSize = size, lineHeight = lineHeight) - if(endIcon != null) - { + if (endIcon != null) { DynamicIcon(endIcon, null, tint = color, modifier = Modifier.padding(start = 4.dp)) } } @@ -147,29 +137,24 @@ fun TextWidgetBlock(params: WidgetBlockParameters, modifier: Modifier = Modifier ////////////////// // final output // ////////////////// - if(widgetBlock.styles["format"] == "alert") - { + if (widgetBlock.styles["format"] == "alert") { Box( - modifier = Modifier + modifier = modifier .padding(4.dp) .fillMaxWidth() .border(1.dp, color, RoundedCornerShape(4.dp)) .background(Brush.horizontalGradient(listOf(color, color)), RoundedCornerShape(4.dp), 0.2f) ) { TextComposable() } - } - else if(widgetBlock.styles["format"] == "banner") - { + } else if (widgetBlock.styles["format"] == "banner") { Box( - contentAlignment = Alignment.Center, modifier = Modifier + contentAlignment = Alignment.Center, modifier = modifier .fillMaxWidth() .background(Brush.horizontalGradient(listOf(color, color)), RectangleShape, 0.2f) ) { TextComposable() } - } - else - { - TextComposable() + } else { + TextComposable(modifier) } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockComposable.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockComposable.kt index b276ed6..3534fe0 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockComposable.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockComposable.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -26,15 +27,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalCustomWidgetRenderer -/*************************************************************************** +/******************************************************************************* ** Composable that takes a widget-block, switches based on the type, and ** then hands off to the block's composable. - ***************************************************************************/ + *******************************************************************************/ @Composable -fun WidgetBlockComposable(params: WidgetBlockParameters, modifier: Modifier = Modifier, disableControls: Boolean = false) -{ - when (params.widgetBlock.blockType) - { +fun WidgetBlockComposable(params: WidgetBlockParameters, modifier: Modifier = Modifier, disableControls: Boolean = false) { + when (params.widgetBlock.blockType) { "AUDIO" -> AudioWidgetBlock(params, modifier) "BUTTON" -> ButtonWidgetBlock(params, modifier, disableControls) "COMPOSITE" -> CompositeWidgetBlock(params, modifier, disableControls) @@ -42,8 +41,7 @@ fun WidgetBlockComposable(params: WidgetBlockParameters, modifier: Modifier = Mo "IMAGE" -> ImageWidgetBlock(params, modifier) "INPUT_FIELD" -> InputFieldWidgetBlock(params, modifier, disableControls) "TEXT" -> TextWidgetBlock(params, modifier) - else -> if(!LocalCustomWidgetRenderer.current(params, modifier, disableControls)) - { + else -> if (!LocalCustomWidgetRenderer.current(params, modifier, disableControls)) { Text("Unsupported block type: ${params.widgetBlock.blockType}") } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockParameters.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockParameters.kt index 1eee5b6..248dbc2 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockParameters.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockParameters.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,14 +25,13 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -/*************************************************************************** +/******************************************************************************* ** common parameter object passed in to any of the *WidgetBlock composables. - ***************************************************************************/ + *******************************************************************************/ data class WidgetBlockParameters( val widgetBlock: WidgetBlock, val actionCallback: ((Map) -> Unit)? = null, val qViewModel: QViewModel? = null, val values: MutableMap = mutableMapOf(), -) -{ -} \ No newline at end of file +) { +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/LoadState.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/LoadState.kt index ae2be92..e386c17 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/LoadState.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/LoadState.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -20,33 +21,37 @@ package com.kingsrook.qqq.frontend.android.mobileapp.viewmodel -/*************************************************************************** - ** - ***************************************************************************/ -sealed interface LoadState -{ +/******************************************************************************* + *******************************************************************************/ +sealed interface LoadState { + /******************************************************************************* + ** Carry a completed value separately from loading and display-safe failure states. + *******************************************************************************/ data class Success(val value: T) : LoadState - data class Error(val message: String) : LoadState - { + + /******************************************************************************* + ** Carry the UI failure message without requiring the presentation layer to inspect transport exceptions. + *******************************************************************************/ + data class Error(val message: String) : LoadState { constructor(e: Exception, m: String) : this("${m}: ${e.message ?: "Error details not available"}") } - data class Loading(val unit: Unit) : LoadState - { + /******************************************************************************* + ** Mark an in-progress operation and retain its start time for loading presentation. + *******************************************************************************/ + data class Loading(val unit: Unit) : LoadState { val start = System.currentTimeMillis() } - companion object - { - /*************************************************************************** - ** - ***************************************************************************/ - fun areAnyErrors(loadStates: List>): Boolean - { - for(loadState in loadStates) - { - if(loadState is Error) - { + /******************************************************************************* + ** Share aggregate loading/error decisions across composed metadata and process screens. + *******************************************************************************/ + companion object { + /******************************************************************************* + *******************************************************************************/ + fun areAnyErrors(loadStates: List>): Boolean { + for (loadState in loadStates) { + if (loadState is Error) { return (true); } } @@ -54,17 +59,13 @@ sealed interface LoadState return (false); } - /*************************************************************************** - ** - ***************************************************************************/ - fun getErrorMessages(loadStates: List>): List - { + /******************************************************************************* + *******************************************************************************/ + fun getErrorMessages(loadStates: List>): List { val rs: MutableList = mutableListOf(); - for(loadState in loadStates) - { - if(loadState is Error) - { + for (loadState in loadStates) { + if (loadState is Error) { rs.add(loadState.message) } } @@ -72,15 +73,11 @@ sealed interface LoadState return (rs) } - /*************************************************************************** - ** - ***************************************************************************/ - fun areAnyLoading(loadStates: List>): Boolean - { - for(loadState in loadStates) - { - if(loadState is Loading) - { + /******************************************************************************* + *******************************************************************************/ + fun areAnyLoading(loadStates: List>): Boolean { + for (loadState in loadStates) { + if (loadState is Loading) { return (true); } } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessAcknowledgment.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessAcknowledgment.kt index 8c1e262..ece5e6a 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessAcknowledgment.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessAcknowledgment.kt @@ -1,15 +1,18 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package com.kingsrook.qqq.frontend.android.mobileapp.viewmodel -/** An extension action explicitly opts into acknowledged retries; ordinary form fields do not. */ +/******************************************************************************* + ** An extension action explicitly opts into acknowledged retries; ordinary form fields do not. + *******************************************************************************/ data class ProcessAcknowledgment( val field: String, val expectedValue: Any, val requiredValues: Map = emptyMap(), val nonEmptyStringFields: Set = emptySet() -) -{ - /** Every advertised receipt requirement must hold before the request can be discarded. */ +) { + /******************************************************************************* + ** Every advertised receipt requirement must hold before the request can be discarded. + *******************************************************************************/ fun matches(responseValues: Map): Boolean = responseValues[field] == expectedValue && requiredValues.all { (name, value) -> responseValues[name] == value } && nonEmptyStringFields.all { name -> (responseValues[name] as? String)?.isNotBlank() == true } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt index 561f79f..a1b807f 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -50,11 +51,9 @@ import java.time.LocalDateTime private const val TAG = "ProcessViewModel" -/*************************************************************************** - ** - ***************************************************************************/ -open class ProcessViewModel : ViewModel() -{ +/******************************************************************************* + *******************************************************************************/ +open class ProcessViewModel : ViewModel() { lateinit var qqqRepository: QQQRepository lateinit var qInstance: QInstance lateinit var processName: String @@ -73,37 +72,42 @@ open class ProcessViewModel : ViewModel() private val clientWork = mutableStateMapOf() val clientWorkBlockReason: String? get() = clientWork.values.firstOrNull() - /** Native controls retain their own operation identity while preventing process exit or advancement. */ - fun setClientWork(key: String, reason: String?) - { - if(reason == null) clientWork.remove(key) else clientWork[key] = reason + /******************************************************************************* + ** Native controls retain their own operation identity while preventing process exit or advancement. + *******************************************************************************/ + fun setClientWork(key: String, reason: String?) { + if (reason == null) clientWork.remove(key) else clientWork[key] = reason } + var unconfirmedSubmission: Boolean by mutableStateOf(false) private set - /** Native navigation never interrupts a submitted mutation or pending native work. */ - fun navigationBlockReason(): String? = when - { + /******************************************************************************* + ** Native navigation never interrupts a submitted mutation or pending native work. + *******************************************************************************/ + fun navigationBlockReason(): String? = when { clientWorkBlockReason != null -> clientWorkBlockReason waitingOnBackend && processUUID != null && initLoadState !is LoadState.Loading -> "Wait for the running request to finish before leaving." else -> null } - /** Leaving is an explicit choice; a missing receipt is not proof that the scan was rejected. */ - fun navigationWarning(): String? = if(pendingStepValues != null) + /******************************************************************************* + ** Leaving is an explicit choice; a missing receipt is not proof that the scan was rejected. + *******************************************************************************/ + fun navigationWarning(): String? = if (pendingStepValues != null) "The last scan is not confirmed and may already be saved. Stay to retry the same scan, or leave to stop retrying. Leaving does not undo a saved scan." - else null + else null - fun requestClose() - { - if(::qViewModel.isInitialized) qViewModel.qNavigator.popStack() else cancel() + /******************************************************************************* + ** Use guarded navigation when attached to the host; standalone process owners still cancel explicitly. + *******************************************************************************/ + fun requestClose() { + if (::qViewModel.isInitialized) qViewModel.qNavigator.popStack() else cancel() } ////////////////////////////////////////////////////////////////////////////////////////////////////////////// // function that this view-model can call, if user chose, within the process, to leave the process. // // so process can do its cleanup work, and then let the navigation event occur to go to a different screen. // - // we are probably missing the reverse of this - where a navigation event (e.g., back arrow) isn't letting // - // this view-model know that it is ending, so, that's a potential todo // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// var closeProcessCallback: (() -> Unit)? = null @@ -228,26 +232,21 @@ open class ProcessViewModel : ViewModel() //////////////////////////////////////////////////////////////////////////// - /*************************************************************************** - ** - ***************************************************************************/ - fun doInit() - { + /******************************************************************************* + *******************************************************************************/ + fun doInit() { viewModelScope.launch() { loadProcessMetaData().join() - if(processMetaData != null) - { + if (processMetaData != null) { initProcess() } } } - /*************************************************************************** - ** - ***************************************************************************/ - fun reset() - { + /******************************************************************************* + *******************************************************************************/ + fun reset() { viewModelScope.coroutineContext.cancelChildren() initializing = false unconfirmedSubmission = false @@ -282,105 +281,88 @@ open class ProcessViewModel : ViewModel() lastInvokedControlCallbackStepInstanceValues = mutableMapOf() } - /*************************************************************************** - ** - ***************************************************************************/ - private fun loadProcessMetaData(): Job - { + /******************************************************************************* + *******************************************************************************/ + private fun loadProcessMetaData(): Job { val processViewModel = this return viewModelScope.launch() { - processMetaDataLoadState = try - { + processMetaDataLoadState = try { val processMetaData = qqqRepository.getProcessMetaData(processName) frontendSteps = processMetaData.frontendSteps ?: emptyList() processViewModel.processMetaData = processMetaData LoadState.Success(processMetaData) - } - catch(e: kotlinx.coroutines.CancellationException) { throw e } - catch(e: Exception) - { + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { LoadState.Error(e, semanticError(e, "Error loading process metadata.")) } } } - /*************************************************************************** - ** - ***************************************************************************/ - private fun initProcess(): Job - { + /******************************************************************************* + *******************************************************************************/ + private fun initProcess(): Job { return viewModelScope.launch() { - try - { + try { initializing = true waitingOnBackend = true initLoadState = LoadState.Loading(Unit) val value = qqqRepository.processInit(processName, emptyMap()) - if(cancelling) - { + if (cancelling) { // Keep the response long enough to cancel the server process whose UUID was not yet known. processUUID = value.processUUID.takeIf { it.isNotBlank() } - if(value !is QJobComplete || !SemanticProcess.terminal(value)) - { + if (value !is QJobComplete || !SemanticProcess.terminal(value)) { val uuid = processUUID ?: error("Initialization did not return a process identity") qqqRepository.processCancel(processName, uuid) } closeProcessCallback?.invoke() - } - else evaluateProcessResult(value) - } - catch(e: kotlinx.coroutines.CancellationException) { throw e } - catch(e: Exception) - { + } else evaluateProcessResult(value) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { waitingOnBackend = false cancellationFailed = cancelling - initLoadState = LoadState.Error(e, if(cancelling) - "The server did not confirm cancellation. The process may still be running. Retry cancellation if available, or contact your administrator." - else semanticError(e, "Error launching process.")) - } - finally - { + initLoadState = LoadState.Error( + e, if (cancelling) + "The server did not confirm cancellation. The process may still be running. Retry cancellation if available, or contact your administrator." + else semanticError(e, "Error launching process.") + ) + } finally { initializing = false cancelling = false } } } - /*************************************************************************** - ** - ***************************************************************************/ - fun setTopLevelError(message: String) - { + /******************************************************************************* + *******************************************************************************/ + fun setTopLevelError(message: String) { topLevelErrorState = LoadState.Error(message) } - /*************************************************************************** - ** - ***************************************************************************/ - fun runStep(eventValues: Map = emptyMap(), acknowledgment: ProcessAcknowledgment? = null) - { - if(waitingOnBackend || cancelling || getNoMoreSteps()) return - if(clientWorkBlockReason != null) return - if(unconfirmedSubmission && pendingStepValues == null) return + /******************************************************************************* + *******************************************************************************/ + fun runStep(eventValues: Map = emptyMap(), acknowledgment: ProcessAcknowledgment? = null) { + if (waitingOnBackend || cancelling || getNoMoreSteps()) return + if (clientWorkBlockReason != null) return + if (unconfirmedSubmission && pendingStepValues == null) return unsupportedReason()?.let { setTopLevelError(it); return } submissionError = null val localProcessUUID = processUUID val localActiveStepName = activeStepName - if(localProcessUUID == null) - { + if (localProcessUUID == null) { setTopLevelError("Missing process UUID. Unable to continue running process.") return } - if(localActiveStepName == "") - { + if (localActiveStepName == "") { setTopLevelError("Missing current step name. Unable to continue running process.") return } @@ -393,26 +375,24 @@ open class ProcessViewModel : ViewModel() val editableFields = declaredFields.filter { it.isEditable && !it.isHidden }.distinctBy { it.name } val protectedFields = declaredFields.filter { !it.isEditable || it.isHidden }.map { it.name }.toSet() fieldErrors = editableFields.mapNotNull { field -> - validateSemanticField(field, if(eventValues.containsKey(field.name)) eventValues[field.name] else semanticInitialValue(field, processValues))?.let { field.name to it } + validateSemanticField(field, if (eventValues.containsKey(field.name)) eventValues[field.name] else semanticInitialValue(field, processValues))?.let { field.name to it } }.toMap() - if(fieldErrors.isNotEmpty()) return + if (fieldErrors.isNotEmpty()) return editableFields.forEach { formFieldNames.add(it.name) } - for(name in formFieldNames) - { - if(name in protectedFields) continue + for (name in formFieldNames) { + if (name in protectedFields) continue val field = editableFields.firstOrNull { it.name == name } - valuesToSubmit[name] = if(field == null) processValues[name] else semanticFieldValue(field, semanticInitialValue(field, processValues)) + valuesToSubmit[name] = if (field == null) processValues[name] else semanticFieldValue(field, semanticInitialValue(field, processValues)) } - for(entry in eventValues.entries) - { - if(entry.key in protectedFields) continue + for (entry in eventValues.entries) { + if (entry.key in protectedFields) continue val field = editableFields.firstOrNull { it.name == entry.key } - valuesToSubmit[entry.key] = if(field == null) entry.value else semanticFieldValue(field, entry.value) + valuesToSubmit[entry.key] = if (field == null) entry.value else semanticFieldValue(field, entry.value) } pendingAcknowledgment = acknowledgment - pendingStepValues = if(acknowledgment != null) valuesToSubmit.toMap() else null + pendingStepValues = if (acknowledgment != null) valuesToSubmit.toMap() else null topLevelErrorState = LoadState.Success(Unit) ///////////////////////////////////////// @@ -424,72 +404,67 @@ open class ProcessViewModel : ViewModel() viewModelScope.launch() { - try - { + try { onSubmitCallback?.invoke() val value = qqqRepository.processStep(processName, localProcessUUID, localActiveStepName, valuesToSubmit, stepTimeoutMillis = 1000) evaluateProcessResult(value) - } - catch(e: kotlinx.coroutines.CancellationException) { throw e } - catch(e: Exception) - { + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { waitingOnBackend = false - if(e is com.kingsrook.qqq.frontend.android.core.controllers.UiApiException) - { + if (e is com.kingsrook.qqq.frontend.android.core.controllers.UiApiException) { fieldErrors = e.fieldErrors.associate { it.field to it.message } - if(e.fieldErrors.isNotEmpty()) submissionError = e.message + if (e.fieldErrors.isNotEmpty()) submissionError = e.message else setTopLevelError(e.message ?: "The server rejected this request.") + } else { + unconfirmedSubmission = true + setTopLevelError( + if (e is retrofit2.HttpException && e.code() in listOf(401, 403)) + "Access denied. Your session may have expired or device approval may have been revoked. Sign in again or contact your administrator." + else if (pendingStepValues != null) "The server did not confirm this request. Retry the same request when connected." + else "The server did not confirm this request. Do not submit it again. Process reference: $localProcessUUID. Contact your administrator to check its result." + ) } - else - { - unconfirmedSubmission = true - setTopLevelError(if(e is retrofit2.HttpException && e.code() in listOf(401, 403)) - "Access denied. Your session may have expired or device approval may have been revoked. Sign in again or contact your administrator." - else if(pendingStepValues != null) "The server did not confirm this request. Retry the same request when connected." - else "The server did not confirm this request. Do not submit it again. Process reference: $localProcessUUID. Contact your administrator to check its result.") - } } } } - /** Retain only the current request in memory so an uncertain response can be retried safely. */ + /******************************************************************************* + ** Retain only the current request in memory so an uncertain response can be retried safely. + *******************************************************************************/ var pendingStepValues: Map? by mutableStateOf(null) private set private var pendingAcknowledgment: ProcessAcknowledgment? = null - /** Explicit retries preserve the scan event ID; no background queue is created. */ - fun retryPendingStep() - { - if(waitingOnBackend || cancelling) return - if(activeJobUUID.isNotEmpty()) - { + /******************************************************************************* + ** Explicit retries preserve the scan event ID; no background queue is created. + *******************************************************************************/ + fun retryPendingStep() { + if (waitingOnBackend || cancelling) return + if (activeJobUUID.isNotEmpty()) { topLevelErrorState = LoadState.Success(Unit) waitingOnBackend = true checkIn() - } - else pendingStepValues?.let { runStep(it, pendingAcknowledgment) } + } else pendingStepValues?.let { runStep(it, pendingAcknowledgment) } } - /** Polling a known job is safe even when its original action was not an idempotent scanner event. */ - fun retryLabel(): String? = when - { + /******************************************************************************* + ** Polling a known job is safe even when its original action was not an idempotent scanner event. + *******************************************************************************/ + fun retryLabel(): String? = when { activeJobUUID.isNotEmpty() -> "Check result again" pendingStepValues != null -> "Retry same request" else -> null } - /*************************************************************************** - ** - ***************************************************************************/ - private fun evaluateProcessResult(value: ProcessStepResult) - { + /******************************************************************************* + *******************************************************************************/ + private fun evaluateProcessResult(value: ProcessStepResult) { lastStepResult = value - when (value) - { - is QJobComplete -> - { + when (value) { + is QJobComplete -> { waitingOnBackend = false processJobCompleteResult(value) @@ -497,70 +472,56 @@ open class ProcessViewModel : ViewModel() // if the initLoadState was loading, then it means we've finally completed the initial load - // // so now we can set a few things to indicate we've progressed past that. // //////////////////////////////////////////////////////////////////////////////////////////////// - if(initLoadState is LoadState.Loading) - { + if (initLoadState is LoadState.Loading) { processUUID = value.processUUID initLoadState = LoadState.Success(value) } } - is QJobError -> - { + is QJobError -> { waitingOnBackend = false activeJobUUID = "" setTopLevelError(value.userFacingError ?: value.error) } - is QJobStarted -> - { + is QJobStarted -> { waitingOnBackend = true jobRunningLastUpdated = LocalDateTime.now() activeJobUUID = value.jobUUID processUUID = value.processUUID } - is QJobRunning -> - { + is QJobRunning -> { waitingOnBackend = true jobRunningMessage = value.message jobRunningLastUpdated = LocalDateTime.now() - if(jobRunningMessage == "") - { + if (jobRunningMessage == "") { jobRunningMessage = "Working..." } val localCurrent = value.current val localTotal = value.total - currentOfTotalMessage = if(localCurrent != null && localTotal != null) - { + currentOfTotalMessage = if (localCurrent != null && localTotal != null) { "${localCurrent} of ${localTotal}" - } - else - { + } else { "" } } - else -> - { + else -> { setTopLevelError("Unexpected response state on a process step call (of type: ${value.javaClass.simpleName})") } } } - /*************************************************************************** - ** - ***************************************************************************/ - private fun processJobCompleteResult(value: QJobComplete) - { + /******************************************************************************* + *******************************************************************************/ + private fun processJobCompleteResult(value: QJobComplete) { unconfirmedSubmission = false - if(pendingAcknowledgment?.matches(value.values) != false) - { + if (pendingAcknowledgment?.matches(value.values) != false) { pendingStepValues = null pendingAcknowledgment = null - } - else - { + } else { setTopLevelError("The server did not acknowledge this scan. Retry the same request when connected.") return } @@ -583,79 +544,65 @@ open class ProcessViewModel : ViewModel() } - /*************************************************************************** - ** - ***************************************************************************/ - private fun updateActiveStepName(newActiveStepName: String) - { + /******************************************************************************* + *******************************************************************************/ + private fun updateActiveStepName(newActiveStepName: String) { activeStepName = newActiveStepName activeStep = null - for((index, qFrontendStepMetaData) in frontendSteps.withIndex()) - { - if(qFrontendStepMetaData.name == activeStepName) - { + for ((index, qFrontendStepMetaData) in frontendSteps.withIndex()) { + if (qFrontendStepMetaData.name == activeStepName) { activeStepIndex = index activeStep = qFrontendStepMetaData return } } - if(!getNoMoreSteps()) setTopLevelError("The server returned an unknown process screen. Update the client or contact your administrator.") + if (!getNoMoreSteps()) setTopLevelError("The server returned an unknown process screen. Update the client or contact your administrator.") } - /*************************************************************************** - ** - ***************************************************************************/ - fun checkIn() - { + /******************************************************************************* + *******************************************************************************/ + fun checkIn() { val localProcessUUID = processUUID val localJobUUID = activeJobUUID - if(localProcessUUID == null) - { + if (localProcessUUID == null) { setTopLevelError("Missing process UUID. Unable to continue running process.") return } - if(localJobUUID.isEmpty() || cancelling) - { + if (localJobUUID.isEmpty() || cancelling) { setTopLevelError("Missing job UUID. Unable to continue running process.") return } viewModelScope.launch() { - try - { + try { val value = qqqRepository.processJobStatus(processName, localProcessUUID, localJobUUID) evaluateProcessResult(value) - } - catch(e: kotlinx.coroutines.CancellationException) { throw e } - catch(e: Exception) - { + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { waitingOnBackend = false setTopLevelError(semanticError(e, "The server did not confirm the running request. Retry to check the same request again.")) } } } - /*************************************************************************** - ** - ***************************************************************************/ - fun cancel() - { - if(cancelling) return + /******************************************************************************* + *******************************************************************************/ + fun cancel() { + if (cancelling) return cancellationFailed = false val uuid = processUUID - if(uuid == null && initializing) - { + if (uuid == null && initializing) { // Cancelling transport here could discard the only response containing the server UUID. cancelling = true return } - if(uuid == null || getNoMoreSteps()) - { + if (uuid == null || getNoMoreSteps()) { viewModelScope.coroutineContext.cancelChildren() closeProcessCallback?.invoke() return @@ -663,51 +610,61 @@ open class ProcessViewModel : ViewModel() cancelling = true viewModelScope.coroutineContext.cancelChildren() viewModelScope.launch { - try - { + try { qqqRepository.processCancel(processName, uuid) pendingStepValues = null pendingAcknowledgment = null closeProcessCallback?.invoke() - } - catch(e: kotlinx.coroutines.CancellationException) { throw e } - catch(e: Exception) - { + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { cancellationFailed = true waitingOnBackend = false - setTopLevelError(if(e is com.kingsrook.qqq.frontend.android.core.controllers.UiApiException) e.message ?: "Cancellation failed." else "The server did not confirm cancellation. Try again when connected.") + setTopLevelError(if (e is com.kingsrook.qqq.frontend.android.core.controllers.UiApiException) e.message ?: "Cancellation failed." else "The server did not confirm cancellation. Try again when connected.") + } finally { + cancelling = false } - finally { cancelling = false } } } - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ fun getNextButtonLabel(): String = "Next" + /******************************************************************************* + ** Expose the server-derived completion decision instead of inferring it from the last displayed step. + *******************************************************************************/ fun getNoMoreSteps(): Boolean = processComplete - fun unsupportedReason(): String? = SemanticProcess.unsupported(activeStep, processValues, - if(::qInstance.isInitialized) qInstance else null, supportedCustomWidgetTypes) + /******************************************************************************* + ** Validate the current server screen against native controls before allowing submission. + *******************************************************************************/ + fun unsupportedReason(): String? = SemanticProcess.unsupported( + activeStep, processValues, + if (::qInstance.isInitialized) qInstance else null, supportedCustomWidgetTypes + ) + /******************************************************************************* + ** Expose semantic server messages only through their explicit exception type and otherwise use a safe fallback. + *******************************************************************************/ private fun semanticError(error: Exception, fallback: String): String = - if(error is com.kingsrook.qqq.frontend.android.core.controllers.UiApiException) error.message ?: fallback else fallback - fun registerFormFieldName(name: String) - { + if (error is com.kingsrook.qqq.frontend.android.core.controllers.UiApiException) error.message ?: fallback else fallback + + /******************************************************************************* + ** Track editable semantic fields so the submission boundary validates the values rendered by this step. + *******************************************************************************/ + fun registerFormFieldName(name: String) { formFieldNames.add(name) } - /*************************************************************************** - ** - ***************************************************************************/ - fun actionCallback(eventValues: Map) - { + /******************************************************************************* + *******************************************************************************/ + fun actionCallback(eventValues: Map) { val registerControlCallbackName = eventValues["registerControlCallbackName"] val registerControlCallbackFunction = eventValues["registerControlCallbackFunction"] - if(registerControlCallbackName is String && registerControlCallbackFunction != null) - { - controlCallbacks[registerControlCallbackName] = eventValues["registerControlCallbackFunction"] as () -> Unit + if (registerControlCallbackName is String && registerControlCallbackFunction != null) { + require(registerControlCallbackFunction is Function0<*>) { "Control callback must take no arguments" } + controlCallbacks[registerControlCallbackName] = { registerControlCallbackFunction.invoke(); Unit } return } @@ -717,112 +674,80 @@ open class ProcessViewModel : ViewModel() var controlCallbackName: String? = null val widgetBlock = eventValues["widgetBlock"] - if(widgetBlock is WidgetBlock) - { - if(widgetBlock.blockType == "BUTTON" && widgetBlock.values["actionCode"] != null) - { - // todo maybe here if (eventValues && eventValues.actionCode && !ProcessWidgetBlockUtils.isActionCodeValid(eventValues.actionCode, activeStep, processValues)) + if (widgetBlock is WidgetBlock) { + if (widgetBlock.blockType == "BUTTON" && widgetBlock.values["actionCode"] != null) { valuesForRunStep = mapOf("actionCode" to widgetBlock.values["actionCode"]) - } - else if(widgetBlock.blockType == "BUTTON" && widgetBlock.values["controlCode"] != null) - { + } else if (widgetBlock.blockType == "BUTTON" && widgetBlock.values["controlCode"] != null) { controlCode = widgetBlock.values["controlCode"].toString() } } - if(eventValues["controlCode"] != null) - { + if (eventValues["controlCode"] != null) { controlCode = eventValues["controlCode"].toString() } - if(controlCode != null) - { + if (controlCode != null) { doRunStep = false val split = controlCode.split(Regex(":"), 2) - if(split.size == 2) - { - if(split[0] == "showModal") - { + if (split.size == 2) { + if (split[0] == "showModal") { processValues[split[1]] = true controlCallbackName = split[1] - } - else if(split[0] == "hideModal") - { + } else if (split[0] == "hideModal") { processValues[split[1]] = false controlCallbackName = split[1] - } - else if(split[0] == "toggleModal") - { + } else if (split[0] == "toggleModal") { val currentValue = processValues[split[1]] - if(currentValue is Boolean) - { + if (currentValue is Boolean) { processValues[split[1]] = !currentValue - } - else - { + } else { processValues[split[1]] = true } controlCallbackName = split[1] - } - else if(split[0] == "invokeControlCallbackOnlyOnce") - { + } else if (split[0] == "invokeControlCallbackOnlyOnce") { controlCallbackName = split[1] - if(lastInvokedControlCallbackStepInstanceValues[controlCallbackName] == stepInstanceCounter) - { + if (lastInvokedControlCallbackStepInstanceValues[controlCallbackName] == stepInstanceCounter) { /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if we've already invoked the named control callback on this step, then un-set the callback name, so we won't invoke it again. // // was: if we've already played audio on this step, then un-set the callback name, so we won't play it again. // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// controlCallbackName = null - } - else - { + } else { /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // else, if we haven't invoked the named control callback on this step, then set flag to indicate that we're calling it now. // // was: else, if we haven't played audio on this step, then set flag to indicate that we're playing it now. // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// lastInvokedControlCallbackStepInstanceValues[controlCallbackName] = stepInstanceCounter } - } - else - { + } else { Log.w(TAG, "Unexpected part[0] (before colon) in controlCode: [${controlCode}]") } - } - else - { + } else { Log.w(TAG, "Expected controlCode to have 2 colon-delimited parts, but was: [${controlCode}]") } } - if(controlCallbackName != null) - { - if(controlCallbacks[controlCallbackName] != null) - { - try - { + if (controlCallbackName != null) { + if (controlCallbacks[controlCallbackName] != null) { + try { controlCallbacks[controlCallbackName]?.invoke() - } - catch(e: kotlinx.coroutines.CancellationException) { throw e } - catch(e: Exception) - { + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { Log.w(TAG, "Error calling control callback for [${controlCallbackName}]", e) } } } - if(doRunStep) - { + if (doRunStep) { runStep(valuesForRunStep) } } - /*************************************************************************** - ** - ***************************************************************************/ - companion object - { + /******************************************************************************* + *******************************************************************************/ + companion object { val factory: ViewModelProvider.Factory = viewModelFactory() { initializer() diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt index ea2972f..7f8ebc9 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -62,16 +63,15 @@ import kotlinx.coroutines.sync.withLock private const val TAG = "QViewModel" -/*************************************************************************** +/******************************************************************************* ** Main view-model for the QQQ Mobile app: ** - high-level data, e.g., what environment, its auth meta-data and instance ** - user/session level status/data - ***************************************************************************/ + *******************************************************************************/ open class QViewModel( val qqqRepository: QQQRepository, private val dataStore: DataStore -) : ViewModel() -{ +) : ViewModel() { private var settingsRepository: SettingsRepository = SettingsRepository(dataStore) //////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -130,10 +130,14 @@ open class QViewModel( var supportedCustomWidgetTypes: Set = emptySet() var activeProcessViewModel: ProcessViewModel? by mutableStateOf(null) - /** A host may end its own session resources before the ordinary QQQ logout. */ + /******************************************************************************* + ** A host may end its own session resources before the ordinary QQQ logout. + *******************************************************************************/ var beforeLogout: suspend () -> Unit = {} - /** Hosts refresh this action after Activity recreation; a retained leave dialog must not call an obsolete UI scope. */ + /******************************************************************************* + ** Hosts refresh this action after Activity recreation; a retained leave dialog must not call an obsolete UI scope. + *******************************************************************************/ var changeServerAction: (() -> Unit)? = null var oauth2LoginPhase: OAuth2LoginPhase by mutableStateOf(OAuth2LoginPhase.READY) @@ -154,12 +158,16 @@ open class QViewModel( private set private val companyLogoutAttempt = OAuth2LogoutAttempt() - /** Opaque login identity: callbacks and responses belong to their initiating server and generation. */ - class AuthenticationAttempt internal constructor(internal val generation: Long, internal val server: String, - internal val metadata: BaseAuthenticationMetaData?) - { + /******************************************************************************* + ** Opaque login identity: callbacks and responses belong to their initiating server and generation. + *******************************************************************************/ + class AuthenticationAttempt internal constructor( + internal val generation: Long, internal val server: String, + internal val metadata: BaseAuthenticationMetaData? + ) { internal var exchangeStarted = false } + private var authenticationGeneration = 0L private var authenticationAttempt: AuthenticationAttempt? = null private var authenticationJob: Job? = null @@ -168,71 +176,80 @@ open class QViewModel( private var auth0ProviderLogoutJob: Job? = null private val settingsMutex = Mutex() - fun prepareAuthentication(): AuthenticationAttempt? - { - if(isAuthenticated || isLogoutRequested || logoutInProgress) return null + /******************************************************************************* + ** Capture the current server and metadata only while a new authentication attempt is eligible. + *******************************************************************************/ + fun prepareAuthentication(): AuthenticationAttempt? { + if (isAuthenticated || isLogoutRequested || logoutInProgress) return null val metadata = (authenticationMetaDataLoadState as? LoadState.Success)?.value ?: return null cancelAuthentication() cancelOAuth2Login() return AuthenticationAttempt(authenticationGeneration, environment.baseUrl, metadata).also { authenticationAttempt = it } } + /******************************************************************************* + ** Reject results after server, metadata, generation or authentication eligibility changes. + *******************************************************************************/ fun isCurrentAuthentication(attempt: AuthenticationAttempt): Boolean = authenticationAttempt === attempt && attempt.generation == authenticationGeneration && attempt.server == environment.baseUrl && attempt.metadata == (authenticationMetaDataLoadState as? LoadState.Success)?.value && !isAuthenticated && !isLogoutRequested && !logoutInProgress - fun cancelAuthentication(attempt: AuthenticationAttempt? = null) - { - if(attempt != null && authenticationAttempt !== attempt) return + /******************************************************************************* + ** Invalidate the attempt before cancelling its job so late responses cannot become the active session. + *******************************************************************************/ + fun cancelAuthentication(attempt: AuthenticationAttempt? = null) { + if (attempt != null && authenticationAttempt !== attempt) return authenticationGeneration++ authenticationAttempt = null authenticationJob?.cancel() authenticationJob = null } - fun completeAuth0Login(attempt: AuthenticationAttempt, accessToken: String, userName: String) - { + /******************************************************************************* + ** Exchange browser credentials only while their initiating authentication attempt remains current. + *******************************************************************************/ + fun completeAuth0Login(attempt: AuthenticationAttempt, accessToken: String, userName: String) { viewModelScope.launch { - if(isCurrentAuthentication(attempt)) exchangeAuthentication(attempt, userName) { + if (isCurrentAuthentication(attempt)) exchangeAuthentication(attempt, userName) { qqqRepository.manageSession(ManageSessionRequest(accessToken = accessToken, uuid = null)) } } } - private fun exchangeAuthentication(attempt: AuthenticationAttempt, userName: String?, exchange: suspend () -> ManageSessionResponse) - { - if(!isCurrentAuthentication(attempt) || attempt.exchangeStarted) return + /******************************************************************************* + ** Adopt a provisional session at most once for the current attempt and revoke abandoned server sessions. + *******************************************************************************/ + private fun exchangeAuthentication(attempt: AuthenticationAttempt, userName: String?, exchange: suspend () -> ManageSessionResponse) { + if (!isCurrentAuthentication(attempt) || attempt.exchangeStarted) return attempt.exchangeStarted = true manageSessionLoadState = LoadState.Loading(Unit) authenticationJob = viewModelScope.launch { var newSession: String? = null var adopted = false - try - { + try { val response = exchange() newSession = response.uuid?.takeIf { it.isNotBlank() } kotlinx.coroutines.currentCoroutineContext().ensureActive() - if(!isCurrentAuthentication(attempt)) return@launch + if (!isCurrentAuthentication(attempt)) return@launch sessionUUID = requireNotNull(newSession) qqqRepository.setSessionUUID(sessionUUID) - if(userName != null) doSetSessionUserFullName(userName) + if (userName != null) doSetSessionUserFullName(userName) manageSessionLoadState = LoadState.Success(response) logInSuccessful() adopted = true authenticationAttempt = null loadQInstance() - } - catch(exception: kotlinx.coroutines.CancellationException) { throw exception } - catch(exception: Exception) - { - if(isCurrentAuthentication(attempt)) manageSessionLoadState = LoadState.Error("Sign-in failed. Check your credentials and connection.") - } - finally - { - if(!adopted && newSession != null) kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { - try { kotlinx.coroutines.withTimeout(3000) { qqqRepository.discardNativeSession(attempt.server, newSession!!) } } - catch(exception: Exception) { /* Never adopt an abandoned credential. */ } + } catch (exception: kotlinx.coroutines.CancellationException) { + throw exception + } catch (exception: Exception) { + if (isCurrentAuthentication(attempt)) manageSessionLoadState = LoadState.Error("Sign-in failed. Check your credentials and connection.") + } finally { + if (!adopted && newSession != null) kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { + try { + kotlinx.coroutines.withTimeout(3000) { qqqRepository.discardNativeSession(attempt.server, newSession) } + } catch (exception: Exception) { /* Never adopt an abandoned credential. */ + } } } } @@ -242,20 +259,17 @@ open class QViewModel( // note - new properties - do you want to add them to clearAll()? // //////////////////////////////////////////////////////////////////// - init - { + init { val generation = authenticationGeneration viewModelScope.launch() { doInitialLoadFromDataStore().join() - if(generation != authenticationGeneration) - { + if (generation != authenticationGeneration) { initialLoadFromDataStoreLoadState = LoadState.Success(Unit) return@launch } loadAuthenticationMetaData().join() - if(generation != authenticationGeneration) - { + if (generation != authenticationGeneration) { initialLoadFromDataStoreLoadState = LoadState.Success(Unit) return@launch } @@ -263,62 +277,52 @@ open class QViewModel( } } - /*************************************************************************** + /******************************************************************************* ** as part of not re-creating all the things upon orientation change - ** own the qNavigator & navController here - ***************************************************************************/ - fun setNavController(navController: NavHostController?) - { - if(!initedQNavigator) - { + *******************************************************************************/ + fun setNavController(navController: NavHostController?) { + if (!initedQNavigator) { qNavigator = QNavigator(navController, this) initedQNavigator = true - } - else - { + } else { qNavigator.navController = navController } } - /*************************************************************************** - ** - ***************************************************************************/ - fun reInitAfterFailure() - { + /******************************************************************************* + *******************************************************************************/ + fun reInitAfterFailure() { clearAll() loadAuthenticationMetaData() } - /*************************************************************************** - ** - ***************************************************************************/ - fun resetApp() - { + /******************************************************************************* + *******************************************************************************/ + fun resetApp() { clearAll() loadAuthenticationMetaData() } - /*************************************************************************** - ** - ***************************************************************************/ - private fun clearAll(preserveLogoutScreen: Boolean = false) - { + /******************************************************************************* + *******************************************************************************/ + private fun clearAll(preserveLogoutScreen: Boolean = false) { cancelAuthentication() cancelOAuth2Login() - if(!preserveLogoutScreen) cancelCompanyLogout() + if (!preserveLogoutScreen) cancelCompanyLogout() //////////////////////////////////////////// // reset all the things to initial values // //////////////////////////////////////////// // storedSessionDance = LoadState.Loading(Unit) // well, not this one - it's only for our very first load. isAuthenticated = false - if(!preserveLogoutScreen) isLogoutRequested = false + if (!preserveLogoutScreen) isLogoutRequested = false sessionUUID = "" storedSessionUUIDLoadState = LoadState.Loading(Unit) sessionUserFullName = "" storedSessionUserFullName = LoadState.Loading(Unit) storedEnvironmentLoadState = LoadState.Loading(Unit) - if(!preserveLogoutScreen) authenticationMetaDataLoadState = LoadState.Loading(Unit) + if (!preserveLogoutScreen) authenticationMetaDataLoadState = LoadState.Loading(Unit) manageSessionLoadState = LoadState.Loading(Unit) qInstanceLoadState = LoadState.Loading(Unit) topBarStatusText = "" @@ -326,45 +330,36 @@ open class QViewModel( activeProcessViewModel = null } - /*************************************************************************** - ** - ***************************************************************************/ - private fun loadAuthenticationMetaData(): Job - { + /******************************************************************************* + *******************************************************************************/ + private fun loadAuthenticationMetaData(): Job { val generation = authenticationGeneration val server = environment.baseUrl return viewModelScope.launch() { - val result: LoadState = try - { + val result: LoadState = try { LoadState.Success(qqqRepository.getAuthenticationMetaData()) - } - catch(e: Exception) - { + } catch (e: Exception) { Log.w(TAG, "Error loading Authentication MetaData") LoadState.Error(e, "Error loading Authentication MetaData") } kotlinx.coroutines.currentCoroutineContext().ensureActive() - if(generation == authenticationGeneration && server == environment.baseUrl) authenticationMetaDataLoadState = result + if (generation == authenticationGeneration && server == environment.baseUrl) authenticationMetaDataLoadState = result } } - /*************************************************************************** - ** - ***************************************************************************/ - open fun manageSession(accessToken: String) - { + /******************************************************************************* + *******************************************************************************/ + open fun manageSession(accessToken: String) { val attempt = prepareAuthentication() ?: return exchangeAuthentication(attempt, sessionUserFullName.takeIf { it.isNotBlank() }) { qqqRepository.manageSession(ManageSessionRequest(accessToken = accessToken, uuid = null)) } } - /*************************************************************************** - ** - ***************************************************************************/ - fun logInFailed() - { + /******************************************************************************* + *******************************************************************************/ + fun logInFailed() { isAuthenticated = false /////////////////////////////////////////////////// @@ -373,20 +368,16 @@ open class QViewModel( storeSessionUUID("") } - /*************************************************************************** - ** - ***************************************************************************/ - fun logInSuccessful() - { + /******************************************************************************* + *******************************************************************************/ + fun logInSuccessful() { isAuthenticated = true storeSessionUUID(sessionUUID) } - /*************************************************************************** - ** - ***************************************************************************/ - fun requestLogout() - { + /******************************************************************************* + *******************************************************************************/ + fun requestLogout() { cancelCompanyLogout() cancelAuthentication() cancelOAuth2Login() @@ -394,21 +385,17 @@ open class QViewModel( qNavigator.goHome(cancelActiveProcess = false) } - /*************************************************************************** - ** - ***************************************************************************/ - fun requestLogin() - { - if(companyLogoutPhase == OAuth2LogoutPhase.PREPARING || companyLogoutPhase == OAuth2LogoutPhase.BROWSER || logoutInProgress) return + /******************************************************************************* + *******************************************************************************/ + fun requestLogin() { + if (companyLogoutPhase == OAuth2LogoutPhase.PREPARING || companyLogoutPhase == OAuth2LogoutPhase.BROWSER || logoutInProgress) return cancelCompanyLogout() isLogoutRequested = false } - /*************************************************************************** - ** - ***************************************************************************/ - private fun doInitialLoadFromDataStore(): Job - { + /******************************************************************************* + *******************************************************************************/ + private fun doInitialLoadFromDataStore(): Job { return viewModelScope.launch() { //////////////////////////////////////// @@ -428,42 +415,32 @@ open class QViewModel( } } - /*************************************************************************** - ** - ***************************************************************************/ - private fun tryLoginFromDataStore(): Job - { + /******************************************************************************* + *******************************************************************************/ + private fun tryLoginFromDataStore(): Job { val generation = authenticationGeneration return viewModelScope.launch() { - when (storedSessionUUIDLoadState) - { - is LoadState.Success -> - { + when (storedSessionUUIDLoadState) { + is LoadState.Success -> { Log.d(TAG, "loadStoredSessionUUID was success") sessionUUID = (storedSessionUUIDLoadState as LoadState.Success).value - if(sessionUUID == "") - { + if (sessionUUID == "") { Log.d(TAG, "loadStoredSessionUUID was empty - no more dance") logInFailed() - } - else - { + } else { Log.d(TAG, "loadStoredSessionUUID was set - trying to load instance now") qqqRepository.setSessionUUID(sessionUUID) loadQInstance().join() - if(generation != authenticationGeneration || isLogoutRequested) return@launch + if (generation != authenticationGeneration || isLogoutRequested) return@launch - when (qInstanceLoadState) - { - is LoadState.Success -> - { + when (qInstanceLoadState) { + is LoadState.Success -> { loadStoredSessionUserFullName().join() - if(generation != authenticationGeneration || isLogoutRequested) return@launch + if (generation != authenticationGeneration || isLogoutRequested) return@launch - sessionUserFullName = when (val it = storedSessionUserFullName) - { + sessionUserFullName = when (val it = storedSessionUserFullName) { is LoadState.Success -> it.value else -> "Unknown" } @@ -472,8 +449,7 @@ open class QViewModel( logInSuccessful() } - else -> - { + else -> { qInstanceLoadState = LoadState.Loading(Unit) Log.d(TAG, "Stored session rejected") logInFailed() @@ -482,8 +458,7 @@ open class QViewModel( } } - else -> - { + else -> { /////////// // noop. // /////////// @@ -495,65 +470,49 @@ open class QViewModel( } } - /*************************************************************************** - ** - ***************************************************************************/ - private fun loadStoredSessionUUID(): Job - { + /******************************************************************************* + *******************************************************************************/ + private fun loadStoredSessionUUID(): Job { return viewModelScope.launch() { - storedSessionUUIDLoadState = try - { + storedSessionUUIDLoadState = try { LoadState.Success(settingsRepository.getSessionUUID()) - } - catch(e: Exception) - { + } catch (e: Exception) { Log.w(TAG, "Error loading stored session UUID") LoadState.Error(e, "Error loading stored session UUID") } } } - /*************************************************************************** - ** - ***************************************************************************/ - private fun loadStoredEnvironmentOrDefault(): Job - { + /******************************************************************************* + *******************************************************************************/ + private fun loadStoredEnvironmentOrDefault(): Job { return viewModelScope.launch() { - try - { + try { var env: Environment? = settingsRepository.getEnvironment() - if(env == null) - { + if (env == null) { Log.d(TAG, "got null environment from settings repo. Using default.") env = defaultEnvironment - } - else - { + } else { Log.d(TAG, "Loaded environment from settings.") } activateEnvironment(env).join() - } - catch(e: Exception) - { + } catch (e: Exception) { Log.w(TAG, "Error loading stored environment") storedEnvironmentLoadState = LoadState.Error(e, "Error loading stored environment") } } } - /*************************************************************************** - ** - ***************************************************************************/ - private fun activateEnvironment(environment: Environment): Job - { + /******************************************************************************* + *******************************************************************************/ + private fun activateEnvironment(environment: Environment): Job { return viewModelScope.launch() { - if(this@QViewModel.environment.baseUrl != environment.baseUrl) - { + if (this@QViewModel.environment.baseUrl != environment.baseUrl) { authenticationMetaDataLoadState = LoadState.Loading(Unit) manageSessionLoadState = LoadState.Loading(Unit) } @@ -564,11 +523,9 @@ open class QViewModel( } } - /*************************************************************************** - ** - ***************************************************************************/ - fun switchEnvironment(environment: Environment, postSwitchCallback: () -> Unit) - { + /******************************************************************************* + *******************************************************************************/ + fun switchEnvironment(environment: Environment, postSwitchCallback: () -> Unit) { cancelCompanyLogout() cancelAuthentication() cancelOAuth2Login() @@ -582,64 +539,57 @@ open class QViewModel( } } - /*************************************************************************** - ** - ***************************************************************************/ - private fun loadStoredSessionUserFullName(): Job - { + /******************************************************************************* + *******************************************************************************/ + private fun loadStoredSessionUserFullName(): Job { return viewModelScope.launch() { - storedSessionUserFullName = try - { + storedSessionUserFullName = try { LoadState.Success(settingsRepository.getSessionUserFullName()) - } - catch(e: Exception) - { + } catch (e: Exception) { Log.w(TAG, "Error loading stored session user full name") LoadState.Error(e, "Error loading stored session user full name") } } } - /*************************************************************************** - ** - ***************************************************************************/ - private fun storeSessionUUID(value: String) - { + /******************************************************************************* + *******************************************************************************/ + private fun storeSessionUUID(value: String) { val generation = authenticationGeneration viewModelScope.launch() { settingsMutex.withLock { - if(generation == authenticationGeneration) settingsRepository.setSessionUUID(value) + if (generation == authenticationGeneration) settingsRepository.setSessionUUID(value) } } } - /*************************************************************************** - ** - ***************************************************************************/ - fun doSetSessionUserFullName(value: String) - { + /******************************************************************************* + *******************************************************************************/ + fun doSetSessionUserFullName(value: String) { sessionUserFullName = value val generation = authenticationGeneration viewModelScope.launch() { settingsMutex.withLock { - if(generation == authenticationGeneration) settingsRepository.setSessionUserFullName(value) + if (generation == authenticationGeneration) settingsRepository.setSessionUserFullName(value) } } } - /*************************************************************************** - ** - ***************************************************************************/ + /******************************************************************************* + *******************************************************************************/ fun logOut(): Job = performLogout() - /** Optional provider material is captured only by the initiating coroutine, never by ViewModel state. */ - private fun performLogout(providerLogout: (suspend () -> Unit)? = null): Job - { - logoutJob?.takeIf { it.isActive || (!isAuthenticated && sessionUUID.isBlank() && - authenticationAttempt == null && oauth2LoginPhase == OAuth2LoginPhase.READY) }?.let { return it } + /******************************************************************************* + ** Optional provider material is captured only by the initiating coroutine, never by ViewModel state. + *******************************************************************************/ + private fun performLogout(providerLogout: (suspend () -> Unit)? = null): Job { + logoutJob?.takeIf { + it.isActive || (!isAuthenticated && sessionUUID.isBlank() && + authenticationAttempt == null && oauth2LoginPhase == OAuth2LoginPhase.READY) + }?.let { return it } cancelAuthentication() cancelOAuth2Login() isAuthenticated = false @@ -654,158 +604,177 @@ open class QViewModel( activeProcessViewModel?.reset() logoutMessage = "Signing out…" var serverConfirmed = false - try - { + try { settingsMutex.withLock { settingsRepository.setSessionUUID(""); settingsRepository.setSessionUserFullName("") } - try { kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() } } - catch(exception: kotlinx.coroutines.CancellationException) { throw exception } - catch(exception: Exception) { /* Core logout and local clearing still run. */ } + try { + kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() } + } catch (exception: kotlinx.coroutines.CancellationException) { + throw exception + } catch (exception: Exception) { /* Core logout and local clearing still run. */ + } serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) { - if(providerLogout == null) qqqRepository.logout() else providerLogout() + if (providerLogout == null) qqqRepository.logout() else providerLogout() true } ?: false - } - catch(exception: kotlinx.coroutines.CancellationException) { throw exception } - catch(exception: Exception) { /* Local sign-out still completes when the network is unavailable. */ } - finally - { + } catch (exception: kotlinx.coroutines.CancellationException) { + throw exception + } catch (exception: Exception) { /* Local sign-out still completes when the network is unavailable. */ + } finally { val current = logoutGeneration == authenticationGeneration && logoutServer == environment.baseUrl - if(current) - { + if (current) { clearAll(preserveLogoutScreen = true) qqqRepository.setSessionUUID("") } kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { settingsMutex.withLock { settingsRepository.setSessionUUID(""); settingsRepository.setSessionUserFullName("") } } - if(current) - { + if (current) { isLogoutRequested = capturedIsLogoutRequested authenticationMetaDataLoadState = capturedAuthLoadState } logoutInProgress = false lastLogoutServerConfirmed = current && serverConfirmed - logoutMessage = if(serverConfirmed) "Signed out." - else "Signed out on this device. Server sign-out could not be confirmed." - if(companyLogoutPhase == OAuth2LogoutPhase.FINISHED) + logoutMessage = if (serverConfirmed) "Signed out." + else "Signed out on this device. Server sign-out could not be confirmed." + if (companyLogoutPhase == OAuth2LogoutPhase.FINISHED) logoutMessage += " Company sign-out did not complete. Your organization may still be signed in in the browser." } }.also { logoutJob = it } } - /** Invoked only by an explicit host opt-in; ordinary requestLogout remains local-only. */ - fun requestCompanyLogout(binding: OAuth2LogoutBinding) - { - if(!isAuthenticated || logoutInProgress || binding.login.server != environment.baseUrl) return + /******************************************************************************* + ** Invoked only by an explicit host opt-in; ordinary requestLogout remains local-only. + *******************************************************************************/ + fun requestCompanyLogout(binding: OAuth2LogoutBinding) { + if (!isAuthenticated || logoutInProgress || binding.login.server != environment.baseUrl) return val metadata = (authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData ?: return val logout = metadata.values.nativeSessionLogout ?: return val login = metadata.values.nativeSessionLogin ?: return - if(binding.login != OAuth2LoginBinding(environment.baseUrl, metadata.name, login.issuer, login.discoveryUrl, - metadata.values.clientId, metadata.values.scopes, binding.login.redirectUri, login.prompt, login.nonceValidation) || + if (binding.login != OAuth2LoginBinding( + environment.baseUrl, metadata.name, login.issuer, login.discoveryUrl, + metadata.values.clientId, metadata.values.scopes, binding.login.redirectUri, login.prompt, login.nonceValidation + ) || login.flow != OAuth2AuthenticationMetaData.SESSION_FLOW || login.codeChallengeMethod != "S256" || login.prompt != "login" || login.nonceValidation != "REQUIRED" || binding.login.redirectUri !in login.redirectUris || binding.postLogoutRedirectUri != binding.login.redirectUri || - logout.flow != OAuth2AuthenticationMetaData.LOGOUT_FLOW || binding.postLogoutRedirectUri !in logout.postLogoutRedirectUris) return + logout.flow != OAuth2AuthenticationMetaData.LOGOUT_FLOW || binding.postLogoutRedirectUri !in logout.postLogoutRedirectUris + ) return requestLogout() companyLogoutBinding = binding companyLogoutPhase = OAuth2LogoutPhase.PREPARING } + /******************************************************************************* + ** Bind company sign-out callbacks to their original server, configuration and logout generation. + *******************************************************************************/ fun isCurrentCompanyLogout(generation: Long, binding: OAuth2LogoutBinding): Boolean = generation == companyLogoutGeneration && companyLogoutBinding == binding && binding.login.server == environment.baseUrl && isLogoutRequested - /** Local invalidation finishes before the browser sees the optional transient hint. */ - suspend fun logOutWithProvider(generation: Long, binding: OAuth2LogoutBinding): NativeLogoutData? - { - if(!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.PREPARING) return null + /******************************************************************************* + ** Local invalidation finishes before the browser sees the optional transient hint. + *******************************************************************************/ + suspend fun logOutWithProvider(generation: Long, binding: OAuth2LogoutBinding): NativeLogoutData? { + if (!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.PREPARING) return null var material: NativeLogoutData? = null - try - { + try { performLogout { material = qqqRepository.logoutWithProvider(binding.postLogoutRedirectUri) }.join() - return material.takeIf { isCurrentCompanyLogout(generation, binding) && companyLogoutPhase == OAuth2LogoutPhase.PREPARING && - lastLogoutServerConfirmed && !isAuthenticated && !logoutInProgress } + return material.takeIf { + isCurrentCompanyLogout(generation, binding) && companyLogoutPhase == OAuth2LogoutPhase.PREPARING && + lastLogoutServerConfirmed && !isAuthenticated && !logoutInProgress + } + } finally { + material = null } - finally { material = null } } - fun beginCompanyLogoutBrowser(generation: Long, binding: OAuth2LogoutBinding, endpoint: String, state: String): Boolean - { - if(!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.PREPARING || - isAuthenticated || logoutInProgress) return false + /******************************************************************************* + ** Allow the provider browser only after local identity is invalidated and the current logout is prepared. + *******************************************************************************/ + fun beginCompanyLogoutBrowser(generation: Long, binding: OAuth2LogoutBinding, endpoint: String, state: String): Boolean { + if (!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.PREPARING || + isAuthenticated || logoutInProgress + ) return false companyLogoutAttempt.begin(binding, endpoint, state) companyLogoutPhase = OAuth2LogoutPhase.BROWSER return true } - /** Dedicated end-session responses never establish QQQ identity or restore cleared private screens. */ - fun completeCompanyLogout(generation: Long, binding: OAuth2LogoutBinding, state: String?, requestState: String?, - requestEndpoint: String?, requestRedirect: String?) - { - if(!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.BROWSER) return + /******************************************************************************* + ** Dedicated end-session responses never establish QQQ identity or restore cleared private screens. + *******************************************************************************/ + fun completeCompanyLogout( + generation: Long, binding: OAuth2LogoutBinding, state: String?, requestState: String?, + requestEndpoint: String?, requestRedirect: String? + ) { + if (!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.BROWSER) return val verified = companyLogoutAttempt.consume(binding, state, requestState, requestEndpoint, requestRedirect) setCompanyLogoutResult(generation, binding, verified) } - /** Cancellation/unavailable paths cannot claim successful provider logout. */ + /******************************************************************************* + ** Cancellation/unavailable paths cannot claim successful provider logout. + *******************************************************************************/ fun finishCompanyLogout(generation: Long, binding: OAuth2LogoutBinding) = setCompanyLogoutResult(generation, binding, false) - private fun setCompanyLogoutResult(generation: Long, binding: OAuth2LogoutBinding, completed: Boolean) - { - if(!isCurrentCompanyLogout(generation, binding)) return - if(companyLogoutPhase == OAuth2LogoutPhase.FINISHED) return + /******************************************************************************* + ** Finalize company sign-out once and distinguish verified provider completion from ordinary local logout. + *******************************************************************************/ + private fun setCompanyLogoutResult(generation: Long, binding: OAuth2LogoutBinding, completed: Boolean) { + if (!isCurrentCompanyLogout(generation, binding)) return + if (companyLogoutPhase == OAuth2LogoutPhase.FINISHED) return companyLogoutAttempt.cancel() companyLogoutPhase = OAuth2LogoutPhase.FINISHED - logoutMessage += if(completed) " Company sign-out completed." - else " Company sign-out did not complete. Your organization may still be signed in in the browser." + logoutMessage += if (completed) " Company sign-out completed." + else " Company sign-out did not complete. Your organization may still be signed in in the browser." } - private fun cancelCompanyLogout() - { + /******************************************************************************* + ** Invalidate pending provider state before another authentication or logout lifetime begins. + *******************************************************************************/ + private fun cancelCompanyLogout() { companyLogoutGeneration++ companyLogoutAttempt.cancel() companyLogoutBinding = null companyLogoutPhase = OAuth2LogoutPhase.IDLE } - /** Activity recreation may render logout again; each logical sign-out opens the provider only once. */ - fun claimAuth0ProviderLogout(): Boolean - { + /******************************************************************************* + ** Activity recreation may render logout again; each logical sign-out opens the provider only once. + *******************************************************************************/ + fun claimAuth0ProviderLogout(): Boolean { val current = logoutJob ?: return false - if(auth0ProviderLogoutJob === current) return false + if (auth0ProviderLogoutJob === current) return false auth0ProviderLogoutJob = current return true } - /*************************************************************************** - ** - ***************************************************************************/ - fun loadQInstance(): Job - { + /******************************************************************************* + *******************************************************************************/ + fun loadQInstance(): Job { val generation = authenticationGeneration val server = environment.baseUrl return viewModelScope.launch() { - val result: LoadState = try - { - // todo - get frontend name & version from ... somewhere! + val result: LoadState = try { val value = qqqRepository.getMetaData("qqq-android", "1.0", applicationName, applicationVersion) LoadState.Success(value) - } - catch(e: Exception) - { + } catch (e: Exception) { Log.w(TAG, "Error loading QInstance") - LoadState.Error(e,"Error loading QInstance") + LoadState.Error(e, "Error loading QInstance") } kotlinx.coroutines.currentCoroutineContext().ensureActive() - if(generation == authenticationGeneration && server == environment.baseUrl) qInstanceLoadState = result + if (generation == authenticationGeneration && server == environment.baseUrl) qInstanceLoadState = result } } - fun prepareOAuth2Login(binding: OAuth2LoginBinding): Long? - { - if(oauth2LoginPhase != OAuth2LoginPhase.READY || isAuthenticated || isLogoutRequested || logoutInProgress || - binding.server != environment.baseUrl || binding.nonceValidation != "REQUIRED") return null + /******************************************************************************* + ** Capture an eligible server-advertised nonce policy before beginning native browser discovery. + *******************************************************************************/ + fun prepareOAuth2Login(binding: OAuth2LoginBinding): Long? { + if (oauth2LoginPhase != OAuth2LoginPhase.READY || isAuthenticated || isLogoutRequested || logoutInProgress || + binding.server != environment.baseUrl || binding.nonceValidation != "REQUIRED" + ) return null cancelAuthentication() oauth2Generation++ oauth2Binding = binding @@ -815,20 +784,27 @@ open class QViewModel( return oauth2Generation } + /******************************************************************************* + ** Reject results from stale server, metadata or generation even when the browser returns successfully. + *******************************************************************************/ fun isCurrentOAuth2Login(generation: Long, binding: OAuth2LoginBinding): Boolean = generation == oauth2Generation && binding == oauth2Binding && binding.server == environment.baseUrl && oauth2Metadata == (authenticationMetaDataLoadState as? LoadState.Success)?.value && !isAuthenticated && !isLogoutRequested && !logoutInProgress - fun beginOAuth2Browser(generation: Long, binding: OAuth2LoginBinding, state: String, nonce: String, verifier: String): Boolean - { - if(!isCurrentOAuth2Login(generation, binding) || oauth2LoginPhase != OAuth2LoginPhase.PREPARING) return false + /******************************************************************************* + ** Bind the original state, nonce and verifier exactly once before entering the browser phase. + *******************************************************************************/ + fun beginOAuth2Browser(generation: Long, binding: OAuth2LoginBinding, state: String, nonce: String, verifier: String): Boolean { + if (!isCurrentOAuth2Login(generation, binding) || oauth2LoginPhase != OAuth2LoginPhase.PREPARING) return false oauth2Attempt.begin(binding, state, nonce, verifier) oauth2LoginPhase = OAuth2LoginPhase.BROWSER return true } - fun cancelOAuth2Login(message: String? = null) - { + /******************************************************************************* + ** Invalidate browser and exchange ownership before dropping pending credentials and cancelling work. + *******************************************************************************/ + fun cancelOAuth2Login(message: String? = null) { oauth2Generation++ oauth2Attempt.cancel() oauth2Binding = null @@ -839,14 +815,17 @@ open class QViewModel( oauth2ExchangeJob = null } - fun completeOAuth2Login(binding: OAuth2LoginBinding, state: String?, requestState: String?, requestNonce: String?, - requestClientId: String, requestRedirect: String, requestPrompt: String?, code: String?) - { + /******************************************************************************* + ** Consume the original browser proof, exchange it once and adopt identity only for the still-current server attempt. + *******************************************************************************/ + fun completeOAuth2Login( + binding: OAuth2LoginBinding, state: String?, requestState: String?, requestNonce: String?, + requestClientId: String, requestRedirect: String, requestPrompt: String?, code: String? + ) { val generation = oauth2Generation - if(oauth2LoginPhase != OAuth2LoginPhase.BROWSER || !isCurrentOAuth2Login(generation, binding)) return + if (oauth2LoginPhase != OAuth2LoginPhase.BROWSER || !isCurrentOAuth2Login(generation, binding)) return val exchange = oauth2Attempt.consume(binding, state, requestState, requestNonce, requestClientId, requestRedirect, requestPrompt, code) - if(exchange == null) - { + if (exchange == null) { cancelOAuth2Login("Sign-in could not be verified. Please start again.") return } @@ -854,14 +833,17 @@ open class QViewModel( oauth2ExchangeJob = viewModelScope.launch { var newSession: String? = null var adopted = false - try - { - val response = qqqRepository.exchangeNativeSession(ManageSessionRequest(code = exchange.code, - codeVerifier = exchange.verifier, redirectUri = exchange.redirectUri, - authenticationFlow = OAuth2AuthenticationMetaData.SESSION_FLOW, expectedNonce = exchange.expectedNonce)) + try { + val response = qqqRepository.exchangeNativeSession( + ManageSessionRequest( + code = exchange.code, + codeVerifier = exchange.verifier, redirectUri = exchange.redirectUri, + authenticationFlow = OAuth2AuthenticationMetaData.SESSION_FLOW, expectedNonce = exchange.expectedNonce + ) + ) newSession = response.uuid?.takeIf { it.isNotBlank() } kotlinx.coroutines.currentCoroutineContext().ensureActive() - if(!isCurrentOAuth2Login(generation, binding)) return@launch + if (!isCurrentOAuth2Login(generation, binding)) return@launch sessionUUID = requireNotNull(newSession) qqqRepository.setSessionUUID(sessionUUID) manageSessionLoadState = LoadState.Success(response) @@ -871,49 +853,44 @@ open class QViewModel( oauth2Binding = null oauth2Metadata = null loadQInstance() - } - catch(exception: kotlinx.coroutines.CancellationException) { throw exception } - catch(exception: Exception) - { - if(generation == oauth2Generation) - { + } catch (exception: kotlinx.coroutines.CancellationException) { + throw exception + } catch (exception: Exception) { + if (generation == oauth2Generation) { oauth2LoginPhase = OAuth2LoginPhase.READY oauth2Binding = null oauth2Metadata = null oauth2LoginMessage = "Sign-in failed. Check your connection or contact your administrator." } - } - finally - { - if(!adopted && newSession != null) kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { - try { kotlinx.coroutines.withTimeout(3000) { qqqRepository.discardNativeSession(binding.server, newSession!!) } } - catch(exception: Exception) { /* Never activate an abandoned session, even when revocation cannot be confirmed. */ } + } finally { + if (!adopted && newSession != null) kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { + try { + kotlinx.coroutines.withTimeout(3000) { qqqRepository.discardNativeSession(binding.server, newSession) } + } catch (exception: Exception) { /* Never activate an abandoned session, even when revocation cannot be confirmed. */ + } } } } } - /** Password remains in this request only; the returned session drives the existing host. */ - fun loginWithPassword(username: String, password: String) - { + /******************************************************************************* + ** Password remains in this request only; the returned session drives the existing host. + *******************************************************************************/ + fun loginWithPassword(username: String, password: String) { val attempt = prepareAuthentication() ?: return exchangeAuthentication(attempt, username) { qqqRepository.loginWithPassword(username, password) } } - /*************************************************************************** - ** - ***************************************************************************/ - fun getURI(path: String): String - { + /******************************************************************************* + *******************************************************************************/ + fun getURI(path: String): String { val uri = qqqRepository.getURI(path) return uri } - /*************************************************************************** - ** - ***************************************************************************/ - companion object - { + /******************************************************************************* + *******************************************************************************/ + companion object { var applicationName: String = "anonymousApp" var applicationVersion: String = "0" var applicationBuildTimestamp: String? = null diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticFields.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticFields.kt index 43623d8..aaa2bd2 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticFields.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticFields.kt @@ -4,60 +4,65 @@ package com.kingsrook.qqq.frontend.android.mobileapp.viewmodel import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType -/** A supplied null is an intentional value; defaults apply only to absent fields. */ +/******************************************************************************* + ** A supplied null is an intentional value; defaults apply only to absent fields. + *******************************************************************************/ internal fun semanticInitialValue(field: QFieldMetaData, values: Map): Any? = - if(values.containsKey(field.name)) values[field.name] else field.defaultValue + if (values.containsKey(field.name)) values[field.name] else field.defaultValue -internal fun semanticFieldValue(field: QFieldMetaData, raw: Any?): Any? -{ - if(raw == null || raw == "") return null +/******************************************************************************* + ** Convert input using the server field type while preserving decimal precision and intentional null values. + *******************************************************************************/ +internal fun semanticFieldValue(field: QFieldMetaData, raw: Any?): Any? { + if (raw == null || raw == "") return null val text = raw.toString() - return when(field.type) - { + return when (field.type) { QFieldType.INTEGER -> text.toIntOrNull() QFieldType.LONG -> text.toLongOrNull() // Preserve decimal precision; the semantic API accepts numeric strings. QFieldType.DECIMAL -> text - QFieldType.BOOLEAN -> if(raw is Boolean) raw else text.toBooleanStrictOrNull() + QFieldType.BOOLEAN -> if (raw is Boolean) raw else text.toBooleanStrictOrNull() else -> raw } } -internal fun validateSemanticField(field: QFieldMetaData, raw: Any?): String? -{ +/******************************************************************************* + ** Apply the advertised field constraints before submission and return an operator-facing validation message. + *******************************************************************************/ +internal fun validateSemanticField(field: QFieldMetaData, raw: Any?): String? { val text = raw?.toString().orEmpty() val rules = field.validation - if(text.isEmpty()) return if(field.isRequired || rules?.required == true) "${field.label} is required." else null - if(field.type in setOf(QFieldType.INTEGER, QFieldType.LONG, QFieldType.BOOLEAN) && semanticFieldValue(field, raw) == null) + if (text.isEmpty()) return if (field.isRequired || rules?.required == true) "${field.label} is required." else null + if (field.type in setOf(QFieldType.INTEGER, QFieldType.LONG, QFieldType.BOOLEAN) && semanticFieldValue(field, raw) == null) return "Enter a valid ${field.type.name.lowercase()} value." val numeric = text.toBigDecimalOrNull() - if(field.type == QFieldType.DECIMAL && numeric == null) return "Enter a valid decimal value." - val validDate = when(field.type) - { + if (field.type == QFieldType.DECIMAL && numeric == null) return "Enter a valid decimal value." + val validDate = when (field.type) { QFieldType.DATE -> runCatching { java.time.LocalDate.parse(text) }.isSuccess QFieldType.TIME -> runCatching { java.time.LocalTime.parse(text) }.isSuccess QFieldType.DATE_TIME -> runCatching { java.time.OffsetDateTime.parse(text) }.isSuccess else -> true } - if(!validDate) return semanticFieldHint(field) ?: "Enter a valid date or time." - if(rules?.maxLength != null && text.length > rules.maxLength!! && rules.tooLongBehavior == "ERROR") + if (!validDate) return semanticFieldHint(field) ?: "Enter a valid date or time." + if (rules?.maxLength != null && text.length > rules.maxLength!! && rules.tooLongBehavior == "ERROR") return "${field.label} must contain at most ${rules.maxLength} characters." - if(numeric != null) - { - rules?.minValue?.toBigDecimalOrNull()?.let { if(numeric < it || (numeric == it && rules.minInclusive == false)) return "${field.label} is below the allowed minimum." } - rules?.maxValue?.toBigDecimalOrNull()?.let { if(numeric > it || (numeric == it && rules.maxInclusive == false)) return "${field.label} exceeds the allowed maximum." } + if (numeric != null) { + rules?.minValue?.toBigDecimalOrNull()?.let { if (numeric < it || (numeric == it && rules.minInclusive == false)) return "${field.label} is below the allowed minimum." } + rules?.maxValue?.toBigDecimalOrNull()?.let { if (numeric > it || (numeric == it && rules.maxInclusive == false)) return "${field.label} exceeds the allowed maximum." } } rules?.pattern?.let { pattern -> val regex = runCatching { Regex(pattern) }.getOrNull() - if(regex != null && !regex.matches(text)) return rules.patternMessage ?: "${field.label} does not match the required format." + if (regex != null && !regex.matches(text)) return rules.patternMessage ?: "${field.label} does not match the required format." } - if(!field.inlinePossibleValueSource.isNullOrEmpty() && field.inlinePossibleValueSource!!.none { it.id.toString() == text }) + if (!field.inlinePossibleValueSource.isNullOrEmpty() && field.inlinePossibleValueSource!!.none { it.id.toString() == text }) return "Choose one of the available values for ${field.label}." return null } -internal fun semanticFieldHint(field: QFieldMetaData): String? = when(field.type) -{ +/******************************************************************************* + ** Describe the wire date/time syntax accepted by semantic validation rather than a locale-dependent format. + *******************************************************************************/ +internal fun semanticFieldHint(field: QFieldMetaData): String? = when (field.type) { QFieldType.DATE -> "Use YYYY-MM-DD." QFieldType.TIME -> "Use HH:mm:ss." QFieldType.DATE_TIME -> "Use YYYY-MM-DDTHH:mm:ssZ or a UTC offset, for example 2026-10-30T09:00:00-05:00." diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcess.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcess.kt index eeb6229..d53115d 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcess.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcess.kt @@ -13,63 +13,76 @@ import com.kingsrook.qqq.frontend.android.core.model.MapStringAny import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -/** Semantic decisions shared by the native UI and the submission boundary. */ -internal object SemanticProcess -{ +/******************************************************************************* + ** Semantic decisions shared by the native UI and the submission boundary. + *******************************************************************************/ +internal object SemanticProcess { // A COMPLETE response omits nextStep when the process has finished; step-list position is irrelevant. + /******************************************************************************* + ** Use the server's next-step/completion contract rather than the position of a displayed step. + *******************************************************************************/ fun terminal(result: QJobComplete) = result.nextStep.isEmpty() || result.values["noMoreSteps"] == true - fun fields(step: QFrontendStepMetaData?, component: QFrontendComponent): List - { - val fields = if(component.type == QComponentType.VIEW_FORM) step?.viewFields else step?.formFields + /******************************************************************************* + ** Select visible fields from the active step and the component's explicit inclusion list. + *******************************************************************************/ + fun fields(step: QFrontendStepMetaData?, component: QFrontendComponent): List { + val fields = if (component.type == QComponentType.VIEW_FORM) step?.viewFields else step?.formFields val included = component.values["includeFieldNames"] as? List<*> return fields.orEmpty().filter { !it.isHidden && (included == null || it.name in included) } } - fun adjust(steps: List, adjustment: ProcessMetaDataAdjustment?): List - { + /******************************************************************************* + ** Apply server metadata adjustments consistently across editable, read-only and record-list fields. + *******************************************************************************/ + fun adjust(steps: List, adjustment: ProcessMetaDataAdjustment?): List { val updated = adjustment?.updatedFields.orEmpty() return (adjustment?.updatedFrontendStepList ?: steps).map { step -> - step.copy(formFields = step.formFields?.map { updated[it.name] ?: it }, + step.copy( + formFields = step.formFields?.map { updated[it.name] ?: it }, viewFields = step.viewFields?.map { updated[it.name] ?: it }, recordListFields = step.recordListFields?.map { updated[it.name] ?: it }) } } - fun unsupported(step: QFrontendStepMetaData?, values: Map, - instance: QInstance?, customBlocks: Set): String? - { - if(step == null) return "The server did not provide a supported current screen." - for(component in step.components.orEmpty()) - { - when(component.type) - { + /******************************************************************************* + ** Stop submission when the current server screen requires a control this native client cannot represent. + *******************************************************************************/ + fun unsupported( + step: QFrontendStepMetaData?, values: Map, + instance: QInstance?, customBlocks: Set + ): String? { + if (step == null) return "The server did not provide a supported current screen." + for (component in step.components.orEmpty()) { + when (component.type) { QComponentType.HELP_TEXT, QComponentType.HTML, QComponentType.PROCESS_SUMMARY_RESULTS, QComponentType.VIEW_FORM -> Unit - QComponentType.EDIT_FORM -> - { + QComponentType.EDIT_FORM -> { val unsupportedField = fields(step, component).firstOrNull { it.type.name in setOf("BLOB", "UNKNOWN") || (it.possibleValueSourceName != null && it.inlinePossibleValueSource == null) } - if(unsupportedField != null) return "${unsupportedField.label} requires an input control unavailable in this client." + if (unsupportedField != null) return "${unsupportedField.label} requires an input control unavailable in this client." } - QComponentType.WIDGET -> - { - val blocks = if(component.values["isAdHocWidget"] == true) component.values["blocks"] as? List<*> - else - { + + QComponentType.WIDGET -> { + val blocks = if (component.values["isAdHocWidget"] == true) component.values["blocks"] as? List<*> + else { val name = component.values["widgetName"] as? String ?: return "The server did not identify this widget." - if(instance?.widgets?.get(name)?.type != "composite") return "This widget is unavailable in this client." + if (instance?.widgets?.get(name)?.type != "composite") return "This widget is unavailable in this client." (values[name] as? Map<*, *>)?.get("subBlocks") as? List<*> } - if(blocks == null || !supportedBlocks(blocks, customBlocks)) return "This screen requires a widget control unavailable in this client." + if (blocks == null || !supportedBlocks(blocks, customBlocks)) return "This screen requires a widget control unavailable in this client." } + else -> return "This screen requires ${component.type.name}, which is unavailable in this client." } } return null } + /******************************************************************************* + ** Validate every nested widget block and its input metadata before treating the screen as supported. + *******************************************************************************/ private fun supportedBlocks(blocks: List<*>, customBlocks: Set): Boolean = blocks.all { raw -> val block = raw as? Map<*, *> ?: return@all false val type = block["blockType"] as? String ?: return@all false @@ -78,41 +91,50 @@ internal object SemanticProcess ((block["subBlocks"] as? List<*>)?.let { supportedBlocks(it, customBlocks) } ?: true) } - /** INPUT_FIELD uses the same typed field metadata as forms; malformed metadata fails closed. */ - fun inputField(values: Map<*, *>?): QFieldMetaData? - { + /******************************************************************************* + ** INPUT_FIELD uses the same typed field metadata as forms; malformed metadata fails closed. + *******************************************************************************/ + fun inputField(values: Map<*, *>?): QFieldMetaData? { val raw = values?.get("fieldMetaData") as? Map<*, *> ?: return null - if(raw.keys.any { it !is String }) return null + if (raw.keys.any { it !is String }) return null val fields = raw.entries.associate { it.key as String to it.value } return runCatching { Json { ignoreUnknownKeys = true }.decodeFromString(Json.encodeToString(MapStringAny(fields))) }.getOrNull()?.takeIf { it.name.isNotBlank() } } - /** Specialized selectors require EDIT_FORM until the widget vocabulary has a native control. */ + /******************************************************************************* + ** Specialized selectors require EDIT_FORM until the widget vocabulary has a native control. + *******************************************************************************/ fun supportedWidgetInput(field: QFieldMetaData): Boolean = field.type !in setOf(QFieldType.UNKNOWN, QFieldType.BLOB, QFieldType.BOOLEAN) && field.possibleValueSourceName == null && field.inlinePossibleValueSource.isNullOrEmpty() + /******************************************************************************* + ** Use the declared conditional value to decide widget visibility without guessing a business action. + *******************************************************************************/ fun widgetVisible(conditional: String?, values: Map): Boolean = conditional == null || values[conditional].let { it != null && it != "" && it != false } - /** Collect widget inputs at submission time as well as render time, so raw callbacks cannot bypass field rules. */ - fun widgetInputFields(step: QFrontendStepMetaData?, values: Map): List - { + /******************************************************************************* + ** Collect widget inputs at submission time as well as render time, so raw callbacks cannot bypass field rules. + *******************************************************************************/ + fun widgetInputFields(step: QFrontendStepMetaData?, values: Map): List { val fields = mutableListOf() - fun visit(blocks: List<*>) - { - for(raw in blocks) - { + + /******************************************************************************* + ** Collect only visible nested widget inputs so hidden controls cannot add unintended submission requirements. + *******************************************************************************/ + fun visit(blocks: List<*>) { + for (raw in blocks) { val block = raw as? Map<*, *> ?: continue - if(!widgetVisible(block["conditional"] as? String, values)) continue - if(block["blockType"] == "INPUT_FIELD") inputField(block["values"] as? Map<*, *>)?.let { fields.add(it) } + if (!widgetVisible(block["conditional"] as? String, values)) continue + if (block["blockType"] == "INPUT_FIELD") inputField(block["values"] as? Map<*, *>)?.let { fields.add(it) } (block["subBlocks"] as? List<*>)?.let(::visit) } } step?.components.orEmpty().filter { it.type == QComponentType.WIDGET }.forEach { component -> - val blocks = if(component.values["isAdHocWidget"] == true) component.values["blocks"] as? List<*> - else (component.values["widgetName"] as? String)?.let { (values[it] as? Map<*, *>)?.get("subBlocks") as? List<*> } + val blocks = if (component.values["isAdHocWidget"] == true) component.values["blocks"] as? List<*> + else (component.values["widgetName"] as? String)?.let { (values[it] as? Map<*, *>)?.get("subBlocks") as? List<*> } blocks?.let(::visit) } return fields diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/TopLevelAppState.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/TopLevelAppState.kt index e756380..c4f351e 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/TopLevelAppState.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/TopLevelAppState.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -28,37 +29,34 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -/*************************************************************************** - ** - ***************************************************************************/ +/******************************************************************************* + *******************************************************************************/ class TopLevelAppState( private val coroutineScope: CoroutineScope, private val qViewModel: QViewModel -) -{ +) { var navDrawerState: DrawerState by mutableStateOf(DrawerState(DrawerValue.Closed)) private set var drawerRequested: Boolean by mutableStateOf(false) private set - /*************************************************************************** - ** - ***************************************************************************/ - fun openNavDrawer() - { + /******************************************************************************* + *******************************************************************************/ + fun openNavDrawer() { drawerRequested = true coroutineScope.launch() { - try { navDrawerState.open() } - finally { drawerRequested = false } + try { + navDrawerState.open() + } finally { + drawerRequested = false + } } } - /*************************************************************************** - ** - ***************************************************************************/ - fun closeNavDrawer() - { + /******************************************************************************* + *******************************************************************************/ + fun closeNavDrawer() { drawerRequested = false coroutineScope.launch() { @@ -66,19 +64,15 @@ class TopLevelAppState( } } - /*************************************************************************** - ** - ***************************************************************************/ - fun logOut() - { + /******************************************************************************* + *******************************************************************************/ + fun logOut() { this.qViewModel.requestLogout() } - /*************************************************************************** - ** - ***************************************************************************/ - fun isLoggedIn(): Boolean - { + /******************************************************************************* + *******************************************************************************/ + fun isLoggedIn(): Boolean { return this.qViewModel.isAuthenticated } diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ExampleUnitTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ExampleUnitTest.kt index 83121ff..86504df 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ExampleUnitTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ExampleUnitTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,18 +23,18 @@ package com.kingsrook.qqq.frontend.android.mobileapp import org.junit.Test -import org.junit.Assert.* +import org.junit.Assert.assertEquals -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest -{ +/******************************************************************************* + ** Example local unit test, which will execute on the development machine (host). + ** See [testing documentation](http://d.android.com/tools/testing). + *******************************************************************************/ +class ExampleUnitTest { + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun addition_isCorrect() - { + fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LoginAttemptTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LoginAttemptTest.kt index 5a17556..01db56c 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LoginAttemptTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LoginAttemptTest.kt @@ -2,24 +2,39 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -class OAuth2LoginAttemptTest -{ - private val binding = OAuth2LoginBinding("https://qqq.example/", "company", "https://id.example/application/o/scanner/", - "https://id.example/application/o/scanner/.well-known/openid-configuration", "server-client", "openid profile", oauth2RedirectUri("io.qrun.mobile.scanner"), "login", "REQUIRED") +/******************************************************************************* + ** + *******************************************************************************/ +class OAuth2LoginAttemptTest { + private val binding = OAuth2LoginBinding( + "https://qqq.example/", "company", "https://id.example/application/o/scanner/", + "https://id.example/application/o/scanner/.well-known/openid-configuration", "server-client", "openid profile", oauth2RedirectUri("io.qrun.mobile.scanner"), "login", "REQUIRED" + ) private val state = "state-random-at-least-sixteen" private val nonce = "nonce-random-at-least-sixteen" private val verifier = "v".repeat(64) - private fun consume(attempt: OAuth2LoginAttempt, context: OAuth2LoginBinding = binding, returnedState: String? = state, + /******************************************************************************* + ** + *******************************************************************************/ + private fun consume( + attempt: OAuth2LoginAttempt, context: OAuth2LoginBinding = binding, returnedState: String? = state, requestState: String? = state, requestNonce: String? = nonce, client: String = binding.clientId, - redirect: String = binding.redirectUri, prompt: String? = binding.prompt, code: String? = "authorization-code") = + redirect: String = binding.redirectUri, prompt: String? = binding.prompt, code: String? = "authorization-code" + ) = attempt.consume(context, returnedState, requestState, requestNonce, client, redirect, prompt, code) - @Test fun successfulResponseUsesOriginalVerifierExactlyOnce() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun successfulResponseUsesOriginalVerifierExactlyOnce() { val attempt = OAuth2LoginAttempt() attempt.begin(binding, state, nonce, verifier) val exchange = requireNotNull(consume(attempt)) @@ -31,13 +46,17 @@ class OAuth2LoginAttemptTest assertNull(consume(attempt)) } - @Test fun staleServerProviderClientScopesAndRedirectNeverExchange() - { - for(context in listOf(binding.copy(server = "https://other.example/"), binding.copy(providerName = "other"), + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun staleServerProviderClientScopesAndRedirectNeverExchange() { + for (context in listOf( + binding.copy(server = "https://other.example/"), binding.copy(providerName = "other"), binding.copy(issuer = "https://other.example/"), binding.copy(discoveryUrl = "https://other.example/discovery"), binding.copy(clientId = "other"), binding.copy(scopes = "openid admin"), binding.copy(redirectUri = "other:/callback"), binding.copy(prompt = "none"), - binding.copy(nonceValidation = ""), binding.copy(nonceValidation = "OPTIONAL"))) - { + binding.copy(nonceValidation = ""), binding.copy(nonceValidation = "OPTIONAL") + )) { val attempt = OAuth2LoginAttempt() attempt.begin(binding, state, nonce, verifier) assertNull(consume(attempt, context)) @@ -45,15 +64,17 @@ class OAuth2LoginAttemptTest } } - @Test fun mismatchMissingCodeAndCancelledAttemptAreTerminal() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun mismatchMissingCodeAndCancelledAttemptAreTerminal() { val checks: List<(OAuth2LoginAttempt) -> OAuth2LoginAttempt.Exchange?> = listOf( { consume(it, returnedState = "wrong") }, { consume(it, requestState = "wrong") }, { consume(it, requestNonce = "wrong") }, { consume(it, client = "wrong") }, { consume(it, redirect = "wrong:/callback") }, { consume(it, prompt = null) }, { consume(it, prompt = "none") }, { consume(it, code = null) }, { consume(it, code = " ") }) - for(check in checks) - { + for (check in checks) { val attempt = OAuth2LoginAttempt() attempt.begin(binding, state, nonce, verifier) assertNull(check(attempt)) @@ -66,8 +87,11 @@ class OAuth2LoginAttemptTest assertNull("Process death has no retained attempt", consume(OAuth2LoginAttempt())) } - @Test fun expiredAttemptFailsClosed() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun expiredAttemptFailsClosed() { var now = 100L val attempt = OAuth2LoginAttempt { now } attempt.begin(binding, state, nonce, verifier) @@ -75,47 +99,69 @@ class OAuth2LoginAttemptTest assertNull(consume(attempt)) } - @Test fun callbackHasExactPackageHostPathAndNoFragment() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun callbackHasExactPackageHostPathAndNoFragment() { val packageName = "io.qrun.mobile.scanner" assertTrue(isOAuth2Callback(binding.redirectUri + "?code=opaque&state=opaque", packageName)) - for(uri in listOf("io.qrun.mobile.scanner.e2e.oauth://callback/oauth2redirect", binding.redirectUri + "/", + for (uri in listOf( + "io.qrun.mobile.scanner.e2e.oauth://callback/oauth2redirect", binding.redirectUri + "/", binding.redirectUri + "#code=opaque", "io.qrun.mobile.scanner.oauth://user@callback/oauth2redirect", "io.qrun.mobile.scanner.oauth://callback:80/oauth2redirect", "io.qrun.mobile.scanner.oauth://callback/%6fauth2redirect", - "io.qrun.mobile.scanner.oauth://elsewhere/oauth2redirect", "https://callback/oauth2redirect")) + "io.qrun.mobile.scanner.oauth://elsewhere/oauth2redirect", "https://callback/oauth2redirect" + )) assertFalse(uri, isOAuth2Callback(uri, packageName)) } - @Test fun configuredUrlsRequireTlsAndRejectEmbeddedCredentialsAndResponseData() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun configuredUrlsRequireTlsAndRejectEmbeddedCredentialsAndResponseData() { assertTrue(isOAuth2HttpsUrl(binding.issuer)) - for(uri in listOf("http://id.example/", "javascript:alert(1)", "https://user:password@id.example/", + for (uri in listOf( + "http://id.example/", "javascript:alert(1)", "https://user:password@id.example/", "https://id.example/?code=opaque", "https://id.example/#fragment", "https:///missing", "https://id.example:0/", - "https://id.example:65536/")) assertFalse(uri, isOAuth2HttpsUrl(uri)) + "https://id.example:65536/" + )) assertFalse(uri, isOAuth2HttpsUrl(uri)) } - @Test fun onlyAnExplicitSupportedSessionGrantForThisPackageEnablesLogin() - { - val grant = OAuth2AuthenticationMetaData.NativeSessionLogin(OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", - binding.issuer, binding.discoveryUrl, listOf(binding.redirectUri), prompt = "login", nonceValidation = "REQUIRED") - val metadata = OAuth2AuthenticationMetaData(binding.providerName, values = OAuth2AuthenticationMetaData.Values( - clientId = binding.clientId, scopes = binding.scopes, nativeClientId = "different-public-client", nativeSessionLogin = grant)) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun onlyAnExplicitSupportedSessionGrantForThisPackageEnablesLogin() { + val grant = OAuth2AuthenticationMetaData.NativeSessionLogin( + OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", + binding.issuer, binding.discoveryUrl, listOf(binding.redirectUri), prompt = "login", nonceValidation = "REQUIRED" + ) + val metadata = OAuth2AuthenticationMetaData( + binding.providerName, values = OAuth2AuthenticationMetaData.Values( + clientId = binding.clientId, scopes = binding.scopes, nativeClientId = "different-public-client", nativeSessionLogin = grant + ) + ) assertEquals(binding, oauth2LoginBinding(metadata, binding.server, "io.qrun.mobile.scanner")) - for(invalid in listOf(null, grant.copy(flow = "BEARER"), grant.copy(codeChallengeMethod = "plain"), + for (invalid in listOf( + null, grant.copy(flow = "BEARER"), grant.copy(codeChallengeMethod = "plain"), grant.copy(prompt = ""), grant.copy(prompt = "none"), grant.copy(prompt = "select_account"), grant.copy(prompt = "login consent"), grant.copy(nonceValidation = ""), grant.copy(nonceValidation = "OPTIONAL"), grant.copy(nonceValidation = "required"), grant.copy(issuer = "http://id.example/"), grant.copy(discoveryUrl = "https://user@id.example/"), - grant.copy(redirectUris = listOf("io.qrun.mobile.scanner.e2e.oauth://callback/oauth2redirect")))) + grant.copy(redirectUris = listOf("io.qrun.mobile.scanner.e2e.oauth://callback/oauth2redirect")) + )) assertNull(oauth2LoginBinding(metadata.copy(values = metadata.values.copy(nativeSessionLogin = invalid)), binding.server, "io.qrun.mobile.scanner")) assertNull(oauth2LoginBinding(metadata, "http://qqq.example/", "io.qrun.mobile.scanner")) } - @Test fun nonceShapeIsBoundedAndCallbackCannotReplaceTheOriginal() - { - for(invalid in listOf("n".repeat(15), "n".repeat(513), "invalid nonce value", "n".repeat(20) + "=", "n".repeat(20) + "+")) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun nonceShapeIsBoundedAndCallbackCannotReplaceTheOriginal() { + for (invalid in listOf("n".repeat(15), "n".repeat(513), "invalid nonce value", "n".repeat(20) + "=", "n".repeat(20) + "+")) assertTrue(runCatching { OAuth2LoginAttempt().begin(binding, state, invalid, verifier) }.isFailure) - for(valid in listOf("n".repeat(16), "n".repeat(512), "A_z-0123456789abc")) - { + for (valid in listOf("n".repeat(16), "n".repeat(512), "A_z-0123456789abc")) { val attempt = OAuth2LoginAttempt() attempt.begin(binding, state, valid, verifier) assertEquals(valid, consume(attempt, requestNonce = valid)?.expectedNonce) diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LogoutAttemptTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LogoutAttemptTest.kt index 7aa4985..0515aed 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LogoutAttemptTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/OAuth2LogoutAttemptTest.kt @@ -3,33 +3,44 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.NativeLogoutData import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -/** Public binding validation and single-use completion do not need Android or a fake provider. */ -class OAuth2LogoutAttemptTest -{ +/******************************************************************************* + ** Public binding validation and single-use completion do not need Android or a fake provider. + *******************************************************************************/ +class OAuth2LogoutAttemptTest { private val callback = "io.qrun.test.oauth://callback/oauth2redirect" private val endpoint = "https://identity.example/end-session" private val state = "synthetic-single-use-state" - private val login = OAuth2LoginBinding("https://qqq.example/", "company", "https://identity.example", - "https://identity.example/discovery", "client", "openid", callback, "login", "REQUIRED") + private val login = OAuth2LoginBinding( + "https://qqq.example/", "company", "https://identity.example", + "https://identity.example/discovery", "client", "openid", callback, "login", "REQUIRED" + ) private val binding = OAuth2LogoutBinding(login, callback) - @Test fun completionIsBoundToOriginProviderCallbackEndpointAndConsumesEvenRejectedResponses() - { - val alternatives = listOf(binding.copy(login = login.copy(server = "https://other.example/")), - binding.copy(login = login.copy(providerName = "other")), binding.copy(postLogoutRedirectUri = "$callback/other")) - for(other in alternatives) - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun completionIsBoundToOriginProviderCallbackEndpointAndConsumesEvenRejectedResponses() { + val alternatives = listOf( + binding.copy(login = login.copy(server = "https://other.example/")), + binding.copy(login = login.copy(providerName = "other")), binding.copy(postLogoutRedirectUri = "$callback/other") + ) + for (other in alternatives) { val attempt = OAuth2LogoutAttempt() attempt.begin(binding, endpoint, state) assertFalse(attempt.consume(other, state, state, endpoint, callback)) assertFalse(attempt.consume(binding, state, state, endpoint, callback)) } - for(values in listOf(listOf("wrong", state, endpoint, callback), listOf(state, "wrong", endpoint, callback), - listOf(state, state, "https://other.example/logout", callback), listOf(state, state, endpoint, "$callback/other"))) - { + for (values in listOf( + listOf("wrong", state, endpoint, callback), listOf(state, "wrong", endpoint, callback), + listOf(state, state, "https://other.example/logout", callback), listOf(state, state, endpoint, "$callback/other") + )) { val attempt = OAuth2LogoutAttempt() attempt.begin(binding, endpoint, state) assertFalse(attempt.consume(binding, values[0], values[1], values[2], values[3])) @@ -37,8 +48,11 @@ class OAuth2LogoutAttemptTest } } - @Test fun stateOnlySuccessIsSingleUseAndCancellationOrExpiryCannotBeRevived() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun stateOnlySuccessIsSingleUseAndCancellationOrExpiryCannotBeRevived() { var now = 0L val attempt = OAuth2LogoutAttempt { now } attempt.begin(binding, endpoint, state) @@ -52,30 +66,57 @@ class OAuth2LogoutAttemptTest assertFalse(attempt.consume(binding, state, state, endpoint, callback)) } - @Test fun materialMustMatchIndependentDiscoveryAndCannotRedirectHintElsewhere() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun materialMustMatchIndependentDiscoveryAndCannotRedirectHintElsewhere() { + /******************************************************************************* + ** + *******************************************************************************/ fun data(issuer: String = login.issuer, url: String = endpoint, redirect: String = callback, hint: String = "synthetic.hint.canary") = NativeLogoutData(issuer, url, redirect, hint) assertTrue(validOAuth2LogoutData(data(), binding, login.issuer, endpoint)) assertFalse(validOAuth2LogoutData(data(), binding, "https://other.example", endpoint)) assertFalse(validOAuth2LogoutData(data(url = "https://other.example/logout"), binding, login.issuer, endpoint)) assertFalse(validOAuth2LogoutData(data(redirect = "$callback/other"), binding, login.issuer, endpoint)) - for(url in listOf("http://identity.example/logout", "https://user@identity.example/logout", "$endpoint#secret", "$endpoint?unexpected=value")) + for (url in listOf("http://identity.example/logout", "https://user@identity.example/logout", "$endpoint#secret", "$endpoint?unexpected=value")) assertFalse(validOAuth2LogoutData(data(url = url), binding, login.issuer, url)) assertFalse(validOAuth2LogoutData(data(hint = " "), binding, login.issuer, endpoint)) assertFalse(data().toString().contains("canary")) } - @Test fun loginCallbackRegistrationDoesNotEnableProviderLogout() - { - val metadata = OAuth2AuthenticationMetaData("company", values = OAuth2AuthenticationMetaData.Values(clientId = "client", scopes = "openid", - nativeSessionLogin = OAuth2AuthenticationMetaData.NativeSessionLogin(OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", - login.issuer, login.discoveryUrl, listOf(callback), "login", "REQUIRED"))) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun loginCallbackRegistrationDoesNotEnableProviderLogout() { + val metadata = OAuth2AuthenticationMetaData( + "company", values = OAuth2AuthenticationMetaData.Values( + clientId = "client", scopes = "openid", + nativeSessionLogin = OAuth2AuthenticationMetaData.NativeSessionLogin( + OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", + login.issuer, login.discoveryUrl, listOf(callback), "login", "REQUIRED" + ) + ) + ) assertNull(oauth2LogoutBinding(metadata, login.server, "io.qrun.test")) - val configured = metadata.copy(values = metadata.values.copy(nativeSessionLogout = - OAuth2AuthenticationMetaData.NativeSessionLogout(OAuth2AuthenticationMetaData.LOGOUT_FLOW, listOf(callback)))) + val configured = metadata.copy( + values = metadata.values.copy( + nativeSessionLogout = + OAuth2AuthenticationMetaData.NativeSessionLogout(OAuth2AuthenticationMetaData.LOGOUT_FLOW, listOf(callback)) + ) + ) assertEquals(binding, oauth2LogoutBinding(configured, login.server, "io.qrun.test")) - assertNull(oauth2LogoutBinding(configured.copy(values = configured.values.copy(nativeSessionLogout = - OAuth2AuthenticationMetaData.NativeSessionLogout("UNKNOWN", listOf(callback)))), login.server, "io.qrun.test")) + assertNull( + oauth2LogoutBinding( + configured.copy( + values = configured.values.copy( + nativeSessionLogout = + OAuth2AuthenticationMetaData.NativeSessionLogout("UNKNOWN", listOf(callback)) + ) + ), login.server, "io.qrun.test" + ) + ) } } diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NetworkPresenceTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NetworkPresenceTest.kt index e18252e..f384461 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NetworkPresenceTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NetworkPresenceTest.kt @@ -1,14 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2004-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class NetworkPresenceTest -{ +/******************************************************************************* + ** + *******************************************************************************/ +class NetworkPresenceTest { + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun losingCellularDoesNotHideAnAvailableWifiNetwork() - { + fun losingCellularDoesNotHideAnAvailableWifiNetwork() { val networks = NetworkPresence() assertTrue(networks.onAvailable(100)) assertTrue(networks.onAvailable(101)) @@ -16,9 +41,11 @@ class NetworkPresenceTest assertFalse(networks.onLost(101)) } + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun duplicateAndStaleCallbacksDoNotInvalidateTheRemainingNetwork() - { + fun duplicateAndStaleCallbacksDoNotInvalidateTheRemainingNetwork() { val networks = NetworkPresence() assertTrue(networks.onAvailable(100)) assertTrue(networks.onAvailable(100)) diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessSoundTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessSoundTest.kt index 7336a32..1574a21 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessSoundTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/ProcessSoundTest.kt @@ -4,28 +4,36 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.processes import org.junit.Assert.assertEquals import org.junit.Test -/** Verify repeated sound playback and background/disposal do not leak or reuse a released player. */ -class ProcessSoundTest -{ - @Test fun unavailableAudioDoesNotFailProcessActionsAndCanRecover() - { +/******************************************************************************* + ** Verify repeated sound playback and background/disposal do not leak or reuse a released player. + *******************************************************************************/ +class ProcessSoundTest { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun unavailableAudioDoesNotFailProcessActionsAndCanRecover() { var attempts = 0 var released = 0 var played = 0 val sound = ProcessSound { val attempt = ++attempts - if(attempt == 1) throw IllegalStateException("Audio service unavailable") - object : ProcessSound.Playback - { - override fun start() - { - if(attempt == 2) throw IllegalStateException("Audio device disconnected") + if (attempt == 1) throw IllegalStateException("Audio service unavailable") + object : ProcessSound.Playback { + /******************************************************************************* + ** + *******************************************************************************/ + override fun start() { + if (attempt == 2) throw IllegalStateException("Audio device disconnected") played++ } - override fun release() - { + + /******************************************************************************* + ** + *******************************************************************************/ + override fun release() { released++ - if(attempt == 2) throw IllegalStateException("Already reclaimed") + if (attempt == 2) throw IllegalStateException("Already reclaimed") } } } @@ -37,17 +45,30 @@ class ProcessSoundTest assertEquals(2, released) } - @Test fun repeatedPlaybackReusesAndLifecycleReleaseIsIdempotent() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun repeatedPlaybackReusesAndLifecycleReleaseIsIdempotent() { val events = mutableListOf() var created = 0 val sound = ProcessSound { val id = ++created events.add("create$id") - object : ProcessSound.Playback - { - override fun start() { events.add("play$id") } - override fun release() { events.add("release$id") } + object : ProcessSound.Playback { + /******************************************************************************* + ** + *******************************************************************************/ + override fun start() { + events.add("play$id") + } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun release() { + events.add("release$id") + } } } repeat(3) { sound.play() } diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/BlockStyleUtilsTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/BlockStyleUtilsTest.kt index 702f11f..3287733 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/BlockStyleUtilsTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/utils/BlockStyleUtilsTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -30,18 +31,19 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class BlockStyleUtilsTest -{ + *******************************************************************************/ +class BlockStyleUtilsTest { - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testGetPaddingValuesFromStyleMap() - { + fun testGetPaddingValuesFromStyleMap() { + /******************************************************************************* + ** + *******************************************************************************/ fun doGetPaddingValues(map: Map<*, *>) = BlockStyleUtils.getPaddingValuesFromStyleMap(mapOf("padding" to map)) assertEquals(PaddingValues(1.dp, 2.dp, 3.dp, 4.dp), doGetPaddingValues(mapOf("left" to 1, "top" to 2, "right" to 3, "bottom" to 4))) @@ -51,13 +53,14 @@ class BlockStyleUtilsTest } - - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testGetSizeValuesFromStyleMap() - { + fun testGetSizeValuesFromStyleMap() { + /******************************************************************************* + ** + *******************************************************************************/ fun doGetSizeValues(map: Map<*, *>) = BlockStyleUtils.getSizeFromStyleMap(map) assertEquals(DpSize(1.dp, 2.dp), doGetSizeValues(mapOf("width" to 1, "height" to 2))) @@ -68,12 +71,11 @@ class BlockStyleUtilsTest } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testGetColorFromString() - { + fun testGetColorFromString() { assertEquals(Color(1, 2, 3), BlockStyleUtils.getColorFromString("010203")) assertEquals(Color(10, 11, 12), BlockStyleUtils.getColorFromString("#0A0B0C")) assertEquals(Color(0xFF, 0xFE, 0xFD, 0xFC), BlockStyleUtils.getColorFromString("#FFFEFDFC")) @@ -86,4 +88,4 @@ class BlockStyleUtilsTest assertNull(BlockStyleUtils.getColorFromString("red")) } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessSubmissionTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessSubmissionTest.kt index fd218fa..b90c815 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessSubmissionTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessSubmissionTest.kt @@ -3,9 +3,19 @@ package com.kingsrook.qqq.frontend.android.mobileapp.viewmodel import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository import com.kingsrook.qqq.frontend.android.core.model.Environment -import com.kingsrook.qqq.frontend.android.core.model.metadata.* -import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.* -import com.kingsrook.qqq.frontend.android.core.model.processes.* +import com.kingsrook.qqq.frontend.android.core.model.metadata.QComponentType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendStepMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance +import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionRequest +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse +import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult +import com.kingsrook.qqq.frontend.android.core.model.processes.QJobComplete +import com.kingsrook.qqq.frontend.android.core.model.processes.QJobStarted import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer import androidx.lifecycle.viewModelScope @@ -13,18 +23,32 @@ import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.* -import org.junit.Assert.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test +/******************************************************************************* + ** + *******************************************************************************/ @OptIn(ExperimentalCoroutinesApi::class) -class ProcessSubmissionTest -{ - @Test fun navigationWaitsForMutationAndNeverReplaysUnknownOrdinaryResult() = runTest() +class ProcessSubmissionTest { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun navigationWaitsForMutationAndNeverReplaysUnknownOrdinaryResult() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val gate = CompletableDeferred() val repository = SubmissionRepository().apply { stepGate = gate; stepFailures = 1 } val model = model(repository) @@ -43,15 +67,19 @@ class ProcessSubmissionTest advanceUntilIdle() assertEquals(1, repository.submissions.size) assertTrue((model.topLevelErrorState as LoadState.Error).message.contains("Do not submit it again")) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun settledMissingScanReceiptAllowsConfirmedLeaveWithoutDiscardingRetry() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun settledMissingScanReceiptAllowsConfirmedLeaveWithoutDiscardingRetry() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val repository = SubmissionRepository() val model = model(repository) model.doInit() @@ -69,15 +97,19 @@ class ProcessSubmissionTest assertNull(model.navigationBlockReason()) assertNull(model.pendingStepValues) assertNull(model.navigationWarning()) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun lostScanResponseAllowsLeaveOnlyAfterRequestSettlesAndPreservesPendingPhotos() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun lostScanResponseAllowsLeaveOnlyAfterRequestSettlesAndPreservesPendingPhotos() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val gate = CompletableDeferred() val repository = SubmissionRepository().apply { stepGate = gate; stepFailures = 1 } val model = model(repository) @@ -96,15 +128,19 @@ class ProcessSubmissionTest model.setClientWork("photo", null) assertNull(model.navigationBlockReason()) assertEquals(1, repository.submissions.size) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun confirmedLeaveKeepsOriginalScanUntilCancellationSucceedsAndNeverResubmits() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun confirmedLeaveKeepsOriginalScanUntilCancellationSucceedsAndNeverResubmits() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val gate = CompletableDeferred() val repository = SubmissionRepository().apply { cancelGate = gate; cancelFailures = 1 } val model = model(repository) @@ -133,21 +169,37 @@ class ProcessSubmissionTest assertEquals(1, repository.submissions.size) assertNull(model.pendingStepValues) assertNull(model.navigationWarning()) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun widgetInputsAreValidatedConvertedAndProtectedAtSubmission() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun widgetInputsAreValidatedConvertedAndProtectedAtSubmission() { runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { - fun block(name: String, type: String, hidden: Boolean = false) = mapOf("blockType" to "INPUT_FIELD", "values" to mapOf( - "fieldMetaData" to mapOf("name" to name, "label" to name, "type" to type, "isRequired" to true, "isHidden" to hidden))) + try { + /******************************************************************************* + ** + *******************************************************************************/ + fun block(name: String, type: String, hidden: Boolean = false) = mapOf( + "blockType" to "INPUT_FIELD", "values" to mapOf( + "fieldMetaData" to mapOf("name" to name, "label" to name, "type" to type, "isRequired" to true, "isHidden" to hidden) + ) + ) + val repository = SubmissionRepository().apply { - inputOverride = QFrontendStepMetaData("input", "Widget", components = listOf(QFrontendComponent(QComponentType.WIDGET, - mapOf("isAdHocWidget" to true, "blocks" to listOf(block("quantity", "INTEGER"), block("secret", "STRING", true)))))) + inputOverride = QFrontendStepMetaData( + "input", "Widget", components = listOf( + QFrontendComponent( + QComponentType.WIDGET, + mapOf("isAdHocWidget" to true, "blocks" to listOf(block("quantity", "INTEGER"), block("secret", "STRING", true))) + ) + ) + ) } val model = model(repository) model.doInit() @@ -159,16 +211,20 @@ class ProcessSubmissionTest model.runStep(mapOf("quantity" to "12", "secret" to "must-not-submit")) advanceUntilIdle() assertEquals(mapOf("quantity" to 12), repository.submissions.single()) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } } - @Test fun ordinaryAsyncJobCanRetryItsStatusWithoutResubmittingTheAction() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun ordinaryAsyncJobCanRetryItsStatusWithoutResubmittingTheAction() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val repository = SubmissionRepository().apply { asynchronous = true; statusFailures = 1 } val model = model(repository) model.doInit() @@ -185,15 +241,19 @@ class ProcessSubmissionTest assertEquals(2, repository.statusCalls) assertEquals("result", model.activeStepName) assertNull(model.retryLabel()) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun cancellingBeforeMetadataReturnsPreventsInitialization() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun cancellingBeforeMetadataReturnsPreventsInitialization() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val gate = CompletableDeferred() val repository = SubmissionRepository().apply { metadataGate = gate } val model = model(repository) @@ -207,15 +267,19 @@ class ProcessSubmissionTest assertEquals(1, closes) assertEquals(0, repository.initializations) assertEquals(0, repository.cancellations) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun cancellingDuringInitializationWaitsForItsIdentityAndCancelsTheServer() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun cancellingDuringInitializationWaitsForItsIdentityAndCancelsTheServer() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val gate = CompletableDeferred() val repository = SubmissionRepository().apply { initGate = gate } val model = model(repository) @@ -232,21 +296,27 @@ class ProcessSubmissionTest assertEquals(1, repository.cancellations) assertEquals(1, closes) assertFalse(model.cancelling) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun completeScannerReceiptIsRequiredBeforeDiscardingTheOriginalEvent() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun completeScannerReceiptIsRequiredBeforeDiscardingTheOriginalEvent() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val repository = SubmissionRepository().apply { responseValues = mapOf("acknowledgedEventId" to "scan-1", "scanSaved" to false) } val model = model(repository) model.doInit() advanceUntilIdle() - model.runStep(mapOf("eventId" to "scan-1", "barcode" to "00123"), - ProcessAcknowledgment("acknowledgedEventId", "scan-1", mapOf("scanSaved" to true), setOf("scanId"))) + model.runStep( + mapOf("eventId" to "scan-1", "barcode" to "00123"), + ProcessAcknowledgment("acknowledgedEventId", "scan-1", mapOf("scanSaved" to true), setOf("scanId")) + ) advanceUntilIdle() assertEquals("scan-1", model.pendingStepValues?.get("eventId")) assertEquals("input", model.activeStepName) @@ -255,15 +325,19 @@ class ProcessSubmissionTest advanceUntilIdle() assertNull(model.pendingStepValues) assertEquals(repository.submissions.first(), repository.submissions.last()) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun omittedNextStepCompletesTheProcessAndCloseDoesNotCancelIt() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun omittedNextStepCompletesTheProcessAndCloseDoesNotCancelIt() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val repository = SubmissionRepository() val model = model(repository) var closed = false @@ -285,15 +359,19 @@ class ProcessSubmissionTest advanceUntilIdle() assertTrue(closed) assertEquals(0, repository.cancellations) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun ordinaryEventIdFieldAdvancesWithoutScannerAcknowledgment() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun ordinaryEventIdFieldAdvancesWithoutScannerAcknowledgment() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val repository = SubmissionRepository() val model = model(repository) model.doInit() @@ -305,15 +383,19 @@ class ProcessSubmissionTest assertEquals("result", model.activeStepName) assertNull(model.pendingStepValues) assertTrue(model.topLevelErrorState is LoadState.Success) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun explicitScanAcknowledgmentRetainsIdentityUntilMatchingRetry() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun explicitScanAcknowledgmentRetainsIdentityUntilMatchingRetry() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val repository = SubmissionRepository() val model = model(repository) model.doInit() @@ -331,15 +413,19 @@ class ProcessSubmissionTest assertEquals("result", model.activeStepName) assertNull(model.pendingStepValues) assertTrue(model.topLevelErrorState is LoadState.Success) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun hiddenRequiredFieldsAreNeitherValidatedNorResubmitted() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun hiddenRequiredFieldsAreNeitherValidatedNorResubmitted() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val repository = SubmissionRepository(hiddenEventId = true) val model = model(repository) model.doInit() @@ -349,15 +435,19 @@ class ProcessSubmissionTest assertTrue(model.fieldErrors.isEmpty()) assertFalse(repository.submissions.single().containsKey("eventId")) assertEquals("result", model.activeStepName) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } - @Test fun explicitLocalLeaveRequiresFailedCancellationAndHonorsWorkGuards() = runTest() + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun explicitLocalLeaveRequiresFailedCancellationAndHonorsWorkGuards() = runTest() { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - try - { + try { val repository = SubmissionRepository().apply { cancelFailures = 2 } val process = model(repository) QAppContainer.availableEnvironments = listOf(Environment("Test", "https://example.invalid")) @@ -406,10 +496,38 @@ class ProcessSubmissionTest assertNull(process.pendingStepValues) assertNull(host.activeProcessViewModel) assertFalse(process.cancellationFailed) + } finally { + Dispatchers.resetMain() } - finally { Dispatchers.resetMain() } } + /******************************************************************************* + ** Erased callback return types are discarded while once-per-step controls retain their identity. + *******************************************************************************/ + @Test + fun controlCallbacksValidateArityAndKeepOncePerStepBehavior() { + val process = ProcessViewModel() + var calls = 0 + val callback: () -> String = { calls++; "ignored" } + process.actionCallback(mapOf("registerControlCallbackName" to "audio", "registerControlCallbackFunction" to callback)) + process.actionCallback(mapOf("controlCode" to "invokeControlCallbackOnlyOnce:audio")) + process.actionCallback(mapOf("controlCode" to "invokeControlCallbackOnlyOnce:audio")) + assertEquals(1, calls) + } + + + /******************************************************************************* + ** Malformed callback registrations must fail before entering the control registry. + *******************************************************************************/ + @Test(expected = IllegalArgumentException::class) + fun nonCallableControlRegistrationIsRejected() { + ProcessViewModel().actionCallback(mapOf("registerControlCallbackName" to "audio", "registerControlCallbackFunction" to "invalid")) + } + + + /******************************************************************************* + ** + *******************************************************************************/ private fun model(repository: SubmissionRepository) = ProcessViewModel().apply() { qqqRepository = repository @@ -417,8 +535,10 @@ class ProcessSubmissionTest qInstance = QInstance() } - private class SubmissionRepository(hiddenEventId: Boolean = false) : QQQRepository - { + /******************************************************************************* + ** + *******************************************************************************/ + private class SubmissionRepository(hiddenEventId: Boolean = false) : QQQRepository { val submissions = mutableListOf>() var responseValues: Map = emptyMap() var nextStep = "result" @@ -434,44 +554,85 @@ class ProcessSubmissionTest var statusFailures = 0 var statusCalls = 0 var inputOverride: QFrontendStepMetaData? = null - private val input = QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), - formFields = listOf(QFieldMetaData("eventId", "Event", type = QFieldType.STRING, isRequired = true, isHidden = hiddenEventId))) + private val input = QFrontendStepMetaData( + "input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), + formFields = listOf(QFieldMetaData("eventId", "Event", type = QFieldType.STRING, isRequired = true, isHidden = hiddenEventId)) + ) private val result = QFrontendStepMetaData("result", "Result", components = emptyList()) - override suspend fun getProcessMetaData(processName: String): QProcessMetaData - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun getProcessMetaData(processName: String): QProcessMetaData { metadataGate?.await() return QProcessMetaData("edit", "Edit", frontendSteps = listOf(inputOverride ?: input, result)) } - override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult { initializations++ initGate?.await() return QJobComplete("process", nextStep = "input") } - override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult { submissions.add(values.toMap()) stepGate?.await() - if(stepFailures-- > 0) throw java.io.IOException("Response lost") - return if(asynchronous) QJobStarted("process", "job") else QJobComplete("process", responseValues, nextStep) + if (stepFailures-- > 0) throw java.io.IOException("Response lost") + return if (asynchronous) QJobStarted("process", "job") else QJobComplete("process", responseValues, nextStep) } - override suspend fun processCancel(processName: String, processUUID: String) - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processCancel(processName: String, processUUID: String) { cancellations++ cancelGate?.await() - if(cancelFailures-- > 0) throw java.io.IOException("Cancellation response lost") + if (cancelFailures-- > 0) throw java.io.IOException("Cancellation response lost") } - override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult { statusCalls++ - if(statusFailures-- > 0) throw java.io.IOException("Unavailable") + if (statusFailures-- > 0) throw java.io.IOException("Unavailable") return QJobComplete("process", responseValues, nextStep) } + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = error("Not used") + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Not used") + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance = error("Not used") - override fun setEnvironment(environment: Environment) { } - override fun setSessionUUID(sessionUUID: String) { } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun setEnvironment(environment: Environment) {} + + /******************************************************************************* + ** + *******************************************************************************/ + override fun setSessionUUID(sessionUUID: String) {} + + /******************************************************************************* + ** + *******************************************************************************/ override fun getURI(path: String): String = error("Not used") } } diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcessTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcessTest.kt index 3c33ff4..f5a2a5a 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcessTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/SemanticProcessTest.kt @@ -1,23 +1,37 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package com.kingsrook.qqq.frontend.android.mobileapp.viewmodel -import com.kingsrook.qqq.frontend.android.core.model.metadata.* +import com.kingsrook.qqq.frontend.android.core.model.metadata.FieldValidation +import com.kingsrook.qqq.frontend.android.core.model.metadata.QComponentType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendStepMetaData import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessMetaDataAdjustment import com.kingsrook.qqq.frontend.android.core.model.processes.QJobComplete import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.components.evaluateWidgetBlocks -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -class SemanticProcessTest -{ +/******************************************************************************* + ** + *******************************************************************************/ +class SemanticProcessTest { private val field = QFieldMetaData("barcode", "Customer barcode", type = QFieldType.STRING) private val other = QFieldMetaData("quantity", "Quantity", type = QFieldType.INTEGER) private val form = QFrontendComponent(QComponentType.EDIT_FORM) private val step = QFrontendStepMetaData("input", "Capture", components = listOf(form), formFields = listOf(field, other)) - @Test fun completionFollowsServerNextStepAndExplicitTerminalFlag() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun completionFollowsServerNextStepAndExplicitTerminalFlag() { assertFalse(SemanticProcess.terminal(QJobComplete("process", nextStep = "input"))) assertFalse(SemanticProcess.terminal(QJobComplete("process", mapOf("noMoreSteps" to false), "input"))) assertFalse(SemanticProcess.terminal(QJobComplete("process", mapOf("noMoreSteps" to "false"), "input"))) @@ -26,31 +40,45 @@ class SemanticProcessTest assertTrue(SemanticProcess.terminal(QJobComplete("process", mapOf("noMoreSteps" to false)))) } - @Test fun componentSubsetUsesFieldNamesAndServerOrder() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun componentSubsetUsesFieldNamesAndServerOrder() { val component = form.copy(values = mapOf("includeFieldNames" to listOf("quantity"))) assertEquals(listOf(other), SemanticProcess.fields(step, component)) assertEquals(emptyList(), SemanticProcess.fields(step, form.copy(values = mapOf("includeFieldNames" to emptyList())))) } - @Test fun viewFormUsesViewFields() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun viewFormUsesViewFields() { assertEquals(listOf(other), SemanticProcess.fields(step.copy(viewFields = listOf(other)), QFrontendComponent(QComponentType.VIEW_FORM))) } - @Test fun hiddenFieldAdjustmentsOmitFieldsFromFormsAndValidation() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun hiddenFieldAdjustmentsOmitFieldsFromFormsAndValidation() { val hidden = field.copy(isHidden = true, isRequired = true, type = QFieldType.UNKNOWN) - val adjusted = SemanticProcess.adjust(listOf(step.copy(viewFields = listOf(field, other))), - ProcessMetaDataAdjustment(updatedFields = mapOf(field.name to hidden))).single() + val adjusted = SemanticProcess.adjust( + listOf(step.copy(viewFields = listOf(field, other))), + ProcessMetaDataAdjustment(updatedFields = mapOf(field.name to hidden)) + ).single() assertEquals(listOf(other), SemanticProcess.fields(adjusted, form)) assertEquals(listOf(other), SemanticProcess.fields(adjusted, QFrontendComponent(QComponentType.VIEW_FORM))) assertNull("A hidden input must not prevent submitting the visible form", SemanticProcess.unsupported(adjusted, emptyMap(), null, emptySet())) assertTrue(SemanticProcess.fields(adjusted, form.copy(values = mapOf("includeFieldNames" to listOf(field.name)))).isEmpty()) } - @Test fun acknowledgmentMatchesOnlyTheExplicitExtensionResponseField() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun acknowledgmentMatchesOnlyTheExplicitExtensionResponseField() { val expected = ProcessAcknowledgment("acknowledgedEventId", "scan-1") assertFalse(expected.matches(emptyMap())) assertFalse(expected.matches(mapOf("acknowledgedEventId" to "scan-2"))) @@ -59,50 +87,80 @@ class SemanticProcessTest assertTrue(ProcessAcknowledgment("savedRequest", "request-1").matches(mapOf("savedRequest" to "request-1"))) } - @Test fun completeReceiptRequiresSuccessAndANonemptyStringIdentity() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun completeReceiptRequiresSuccessAndANonemptyStringIdentity() { val policy = ProcessAcknowledgment("acknowledgedEventId", "scan-1", mapOf("scanSaved" to true), setOf("scanId")) val complete = mapOf("acknowledgedEventId" to "scan-1", "scanSaved" to true, "scanId" to "receipt") assertTrue(policy.matches(complete)) - for(name in complete.keys) assertFalse(policy.matches(complete - name)) - for(value in listOf(false, "true", null)) assertFalse(policy.matches(complete + ("scanSaved" to value))) - for(value in listOf("", " ", 123, null)) assertFalse(policy.matches(complete + ("scanId" to value))) + for (name in complete.keys) assertFalse(policy.matches(complete - name)) + for (value in listOf(false, "true", null)) assertFalse(policy.matches(complete + ("scanSaved" to value))) + for (value in listOf("", " ", 123, null)) assertFalse(policy.matches(complete + ("scanId" to value))) } - @Test fun explicitNullDoesNotUseTheDefault() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun explicitNullDoesNotUseTheDefault() { val defaulted = field.copy(defaultValue = "default") assertEquals("default", semanticInitialValue(defaulted, emptyMap())) assertNull(semanticInitialValue(defaulted, mapOf(field.name to null))) assertEquals("entered", semanticInitialValue(defaulted, mapOf(field.name to "entered"))) } - @Test fun widgetInputMetadataUsesTypedSupportAndRejectsUnsupportedFields() - { - fun screen(metadata: Map) = step.copy(components = listOf(QFrontendComponent(QComponentType.WIDGET, - mapOf("isAdHocWidget" to true, "blocks" to listOf(mapOf("blockType" to "INPUT_FIELD", "values" to mapOf("fieldMetaData" to metadata))))))) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun widgetInputMetadataUsesTypedSupportAndRejectsUnsupportedFields() { + /******************************************************************************* + ** + *******************************************************************************/ + fun screen(metadata: Map) = step.copy( + components = listOf( + QFrontendComponent( + QComponentType.WIDGET, + mapOf("isAdHocWidget" to true, "blocks" to listOf(mapOf("blockType" to "INPUT_FIELD", "values" to mapOf("fieldMetaData" to metadata)))) + ) + ) + ) + val metadata = mapOf("name" to "quantity", "label" to "Quantity", "type" to "INTEGER", "isRequired" to true) assertNull(SemanticProcess.unsupported(screen(metadata), emptyMap(), null, emptySet())) assertEquals(QFieldType.INTEGER, SemanticProcess.widgetInputFields(screen(metadata), emptyMap()).single().type) - for(type in listOf("FUTURE", "BLOB", "BOOLEAN")) assertNotNull(SemanticProcess.unsupported(screen(metadata + ("type" to type)), emptyMap(), null, emptySet())) + for (type in listOf("FUTURE", "BLOB", "BOOLEAN")) assertNotNull(SemanticProcess.unsupported(screen(metadata + ("type" to type)), emptyMap(), null, emptySet())) assertNotNull(SemanticProcess.unsupported(screen(metadata - "type"), emptyMap(), null, emptySet())) assertNotNull(SemanticProcess.unsupported(screen(metadata + ("possibleValueSourceName" to "remote")), emptyMap(), null, emptySet())) assertNull(SemanticProcess.unsupported(screen(metadata + mapOf("isHidden" to true, "type" to "FUTURE")), emptyMap(), null, emptySet())) } - @Test fun conditionalWidgetInputsMatchTheRenderedFields() - { - val input = mapOf("blockType" to "INPUT_FIELD", "blockId" to "input", "values" to mapOf( - "fieldMetaData" to mapOf("name" to "code", "label" to "Code", "type" to "STRING", "isRequired" to true))) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun conditionalWidgetInputsMatchTheRenderedFields() { + val input = mapOf( + "blockType" to "INPUT_FIELD", "blockId" to "input", "values" to mapOf( + "fieldMetaData" to mapOf("name" to "code", "label" to "Code", "type" to "STRING", "isRequired" to true) + ) + ) val composite = mapOf("blockType" to "COMPOSITE", "blockId" to "group", "conditional" to "showCode", "subBlocks" to listOf(input)) - val screen = step.copy(components = listOf(QFrontendComponent(QComponentType.WIDGET, - mapOf("isAdHocWidget" to true, "blocks" to listOf(composite))))) - for(value in listOf(null, "", false, true, "yes")) - { + val screen = step.copy( + components = listOf( + QFrontendComponent( + QComponentType.WIDGET, + mapOf("isAdHocWidget" to true, "blocks" to listOf(composite)) + ) + ) + ) + for (value in listOf(null, "", false, true, "yes")) { val values = mutableMapOf("showCode" to value) val rendered = mutableListOf(WidgetBlock.fromMap(composite)!!) evaluateWidgetBlocks(rendered, values) - val expectedCount = if(value == true || value == "yes") 1 else 0 + val expectedCount = if (value == true || value == "yes") 1 else 0 assertEquals(expectedCount, rendered.size) assertEquals(expectedCount, SemanticProcess.widgetInputFields(screen, values).size) val named = screen.copy(components = listOf(QFrontendComponent(QComponentType.WIDGET, mapOf("widgetName" to "named")))) @@ -110,16 +168,22 @@ class SemanticProcessTest } } - @Test fun fieldChangesApplyWithoutReplacingStepList() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun fieldChangesApplyWithoutReplacingStepList() { val updated = field.copy(label = "Updated barcode", isRequired = true) val result = SemanticProcess.adjust(listOf(step), ProcessMetaDataAdjustment(updatedFields = mapOf(field.name to updated))) assertEquals(updated, result.single().formFields!!.first()) assertEquals(other, result.single().formFields!![1]) } - @Test fun stepReplacementAndFieldUpdatesApplyTogether() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun stepReplacementAndFieldUpdatesApplyTogether() { val replacement = step.copy(name = "replacement", label = "New server screen") val updated = field.copy(label = "New label") val result = SemanticProcess.adjust(listOf(step), ProcessMetaDataAdjustment(listOf(replacement), mapOf(field.name to updated))) @@ -128,17 +192,33 @@ class SemanticProcessTest assertTrue(SemanticProcess.adjust(listOf(step), ProcessMetaDataAdjustment(updatedFrontendStepList = emptyList())).isEmpty()) } - @Test fun unknownAndUnsupportedComponentsCannotAdvance() - { - for(type in listOf(QComponentType.UNKNOWN, QComponentType.RECORD_LIST, QComponentType.BULK_EDIT_FORM)) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun unknownAndUnsupportedComponentsCannotAdvance() { + for (type in listOf(QComponentType.UNKNOWN, QComponentType.RECORD_LIST, QComponentType.BULK_EDIT_FORM)) assertNotNull(SemanticProcess.unsupported(step.copy(components = listOf(QFrontendComponent(type))), emptyMap(), null, emptySet())) assertNull(SemanticProcess.unsupported(step, emptyMap(), null, emptySet())) } - @Test fun customBlocksRequireRegistrationAndNestedUnknownBlocksBlockScreen() - { - fun screen(blocks: List>) = step.copy(components = listOf(QFrontendComponent(QComponentType.WIDGET, - mapOf("isAdHocWidget" to true, "blocks" to blocks)))) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun customBlocksRequireRegistrationAndNestedUnknownBlocksBlockScreen() { + /******************************************************************************* + ** + *******************************************************************************/ + fun screen(blocks: List>) = step.copy( + components = listOf( + QFrontendComponent( + QComponentType.WIDGET, + mapOf("isAdHocWidget" to true, "blocks" to blocks) + ) + ) + ) + val scanner = screen(listOf(mapOf("blockType" to "SCANNER"))) assertNotNull(SemanticProcess.unsupported(scanner, emptyMap(), null, emptySet())) assertNull(SemanticProcess.unsupported(scanner, emptyMap(), null, setOf("SCANNER"))) @@ -146,14 +226,20 @@ class SemanticProcessTest assertNotNull(SemanticProcess.unsupported(unknown, emptyMap(), null, setOf("SCANNER"))) } - @Test fun remotePossibleValuesAndUnknownInputTypesAreExplicitlyUnavailable() - { - for(unsupportedField in listOf(field.copy(possibleValueSourceName = "remote"), field.copy(type = QFieldType.UNKNOWN))) + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun remotePossibleValuesAndUnknownInputTypesAreExplicitlyUnavailable() { + for (unsupportedField in listOf(field.copy(possibleValueSourceName = "remote"), field.copy(type = QFieldType.UNKNOWN))) assertNotNull(SemanticProcess.unsupported(step.copy(formFields = listOf(unsupportedField)), emptyMap(), null, emptySet())) } - @Test fun requiredNumericAndLengthValidation() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun requiredNumericAndLengthValidation() { assertNotNull(validateSemanticField(field.copy(isRequired = true), "")) assertNull(validateSemanticField(field, "")) assertNotNull(validateSemanticField(other, "x")) @@ -162,8 +248,11 @@ class SemanticProcessTest assertNull(validateSemanticField(field.copy(validation = FieldValidation(maxLength = 3, tooLongBehavior = "TRUNCATE")), "1234")) } - @Test fun patternAndExactNumericBounds() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun patternAndExactNumericBounds() { assertNotNull(validateSemanticField(field.copy(validation = FieldValidation(pattern = "[A-Z]+")), "ABC1")) assertNull(validateSemanticField(field.copy(validation = FieldValidation(pattern = "[A-Z]+")), "ABC")) val bounded = other.copy(validation = FieldValidation(minValue = "1", minInclusive = false, maxValue = "3")) @@ -172,8 +261,11 @@ class SemanticProcessTest assertNotNull(validateSemanticField(bounded, "4")) } - @Test fun temporalInputsAreValidatedAsIsoValuesWithExplicitTimezone() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun temporalInputsAreValidatedAsIsoValuesWithExplicitTimezone() { assertNull(validateSemanticField(field.copy(type = QFieldType.DATE), "2026-10-30")) assertNotNull(validateSemanticField(field.copy(type = QFieldType.DATE), "2026-02-30")) assertNull(validateSemanticField(field.copy(type = QFieldType.TIME), "09:30:00")) diff --git a/qqqsampleandroidmobileapp/build.gradle.kts b/qqqsampleandroidmobileapp/build.gradle.kts index 7a9594b..3cdec18 100644 --- a/qqqsampleandroidmobileapp/build.gradle.kts +++ b/qqqsampleandroidmobileapp/build.gradle.kts @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/AuthenticationLifecycleTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/AuthenticationLifecycleTests.kt index 8fa4123..b62fb80 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/AuthenticationLifecycleTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/AuthenticationLifecycleTests.kt @@ -49,31 +49,41 @@ import org.junit.After import org.junit.Rule import org.junit.Test -/** Deliberately delayed, cancellation-resistant responses model a server finishing after local sign-out. */ -class AuthenticationLifecycleTests : BaseTest() -{ - @get:Rule val ui = createAndroidComposeRule() +/******************************************************************************* + ** Deliberately delayed, cancellation-resistant responses model a server finishing after local sign-out. + *******************************************************************************/ +class AuthenticationLifecycleTests : BaseTest() { + @get:Rule + val ui = createAndroidComposeRule() private val first = Environment("First", "https://first.example.test/") private val second = Environment("Second", "https://second.example.test/") private val fixtures = mutableListOf() - @After fun releaseHeldResponses() - { + /******************************************************************************* + ** + *******************************************************************************/ + @After + fun releaseHeldResponses() { fixtures.forEach { it.release.complete(Unit); it.releaseLogout.complete(Unit); it.store.releaseWrite.complete(Unit) } } - private class HeldStore : DataStore - { + /******************************************************************************* + ** + *******************************************************************************/ + private class HeldStore : DataStore { private val delegate = MockPreferencesDataStore() val preferences get() = delegate.preferences override val data get() = delegate.data val holdNextWrite = AtomicBoolean() val writeStarted = CompletableDeferred() val releaseWrite = CompletableDeferred() + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = delegate.updateData { val next = transform(it) - if(holdNextWrite.compareAndSet(true, false)) - { + if (holdNextWrite.compareAndSet(true, false)) { writeStarted.complete(Unit) releaseWrite.await() } @@ -81,8 +91,10 @@ class AuthenticationLifecycleTests : BaseTest() } } - private inner class Fixture(holdFirst: Boolean, holdLogout: Boolean, holdFinalWrite: Boolean) - { + /******************************************************************************* + ** + *******************************************************************************/ + private inner class Fixture(holdFirst: Boolean, holdLogout: Boolean, holdFinalWrite: Boolean) { val store = HeldStore() val calls = AtomicInteger() val cookie = AtomicReference("") @@ -94,42 +106,85 @@ class AuthenticationLifecycleTests : BaseTest() val providerLogoutCalls = AtomicInteger() val callback = AtomicReference?>() val logoutCallback = AtomicReference?>() - val metadata = Auth0AuthenticationMetaData("Auth0", Auth0AuthenticationMetaDataValues( - "https://identity.example.test", "public-client", "https://api.example.test")) - val repository = object : QQQRepository by SampleAppMockQQQRepository() - { + val metadata = Auth0AuthenticationMetaData( + "Auth0", Auth0AuthenticationMetaDataValues( + "https://identity.example.test", "public-client", "https://api.example.test" + ) + ) + val repository = object : QQQRepository by SampleAppMockQQQRepository() { + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun getAuthenticationMetaData() = metadata - override fun setSessionUUID(sessionUUID: String) { cookie.set(sessionUUID) } - override suspend fun logout() - { - logoutCalls.incrementAndGet() - if(holdLogout) releaseLogout.await() - cookie.set("") - if(holdFinalWrite) store.holdNextWrite.set(true) + + /******************************************************************************* + ** + *******************************************************************************/ + override fun setSessionUUID(sessionUUID: String) { + cookie.set(sessionUUID) } - private suspend fun exchange(): ManageSessionResponse - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun logout() { + logoutCalls.incrementAndGet() + if (holdLogout) releaseLogout.await() + cookie.set("") + if (holdFinalWrite) store.holdNextWrite.set(true) + } + + /******************************************************************************* + ** + *******************************************************************************/ + private suspend fun exchange(): ManageSessionResponse { val number = calls.incrementAndGet() - if(holdFirst && number == 1) withContext(NonCancellable) { release.await() } + if (holdFirst && number == 1) withContext(NonCancellable) { release.await() } return ManageSessionResponse("synthetic-session-$number") } + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun loginWithPassword(username: String, password: String) = exchange() + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun manageSession(body: ManageSessionRequest) = exchange() - override suspend fun discardNativeSession(server: String, session: String) { discarded.add(server to session) } + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun discardNativeSession(server: String, session: String) { + discarded.add(server to session) + } } lateinit var model: QViewModel - val browser = object : Auth0Browser - { - override fun login(context: Context, account: Auth0, audience: String, scheme: String, - callback: Callback) - { browserCalls.incrementAndGet(); this@Fixture.callback.set(callback) } - override fun logout(context: Context, account: Auth0, scheme: String, callback: Callback) - { providerLogoutCalls.incrementAndGet(); logoutCallback.set(callback) } + val browser = object : Auth0Browser { + /******************************************************************************* + ** + *******************************************************************************/ + override fun login( + context: Context, account: Auth0, audience: String, scheme: String, + callback: Callback + ) { + browserCalls.incrementAndGet(); this@Fixture.callback.set(callback) + } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun logout(context: Context, account: Auth0, scheme: String, callback: Callback) { + providerLogoutCalls.incrementAndGet(); logoutCallback.set(callback) + } } } - private fun start(holdFirst: Boolean = true, holdLogout: Boolean = false, holdFinalWrite: Boolean = false): Fixture - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun start(holdFirst: Boolean = true, holdLogout: Boolean = false, holdFinalWrite: Boolean = false): Fixture { QAppContainer.availableEnvironments = listOf(first, second) QAuth0Service.scheme = "qqq-test" val fixture = Fixture(holdFirst, holdLogout, holdFinalWrite).also { fixtures.add(it) } @@ -138,8 +193,10 @@ class AuthenticationLifecycleTests : BaseTest() return fixture } - private fun assertCleared(fixture: Fixture) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun assertCleared(fixture: Fixture) { ui.runOnIdle { assertFalse(fixture.model.isAuthenticated) assertEquals("", fixture.model.sessionUUID) @@ -148,8 +205,11 @@ class AuthenticationLifecycleTests : BaseTest() } } - @Test fun delayedPasswordAfterLogoutCannotRestoreRetainedModel() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun delayedPasswordAfterLogoutCannotRestoreRetainedModel() { val fixture = start() ui.runOnIdle { fixture.model.loginWithPassword("operator", "synthetic-password") } ui.waitUntil(5000) { fixture.calls.get() == 1 } @@ -161,8 +221,11 @@ class AuthenticationLifecycleTests : BaseTest() assertEquals(first.baseUrl to "synthetic-session-1", fixture.discarded.single()) } - @Test fun delayedPasswordFromPreviousServerCannotReplaceNewOperator() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun delayedPasswordFromPreviousServerCannotReplaceNewOperator() { val fixture = start() ui.runOnIdle { fixture.model.loginWithPassword("old", "synthetic-password") } ui.waitUntil(5000) { fixture.calls.get() == 1 } @@ -181,21 +244,31 @@ class AuthenticationLifecycleTests : BaseTest() assertEquals(first.baseUrl to "synthetic-session-1", fixture.discarded.single()) } - private fun showAuth0(fixture: Fixture) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun showAuth0(fixture: Fixture) { ui.setContent { CompositionLocalProvider(LocalAuth0Browser provides fixture.browser) { QAuth0Login(fixture.model) } } ui.waitUntil(5000) { fixture.callback.get() != null } } - private fun credentials(): Credentials - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun credentials(): Credentials { + /******************************************************************************* + ** + *******************************************************************************/ fun encoded(value: String) = Base64.encodeToString(value.toByteArray(), Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) val token = encoded("""{"alg":"none"}""") + "." + encoded("""{"sub":"synthetic","nickname":"Operator"}""") + ".synthetic" return Credentials(token, "synthetic-provider-token", "Bearer", null, Date(System.currentTimeMillis() + 60000), "openid") } - @Test fun lateAuth0BrowserCallbackAfterLogoutSendsNoToken() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun lateAuth0BrowserCallbackAfterLogoutSendsNoToken() { val fixture = start() showAuth0(fixture) val callback = requireNotNull(fixture.callback.get()) @@ -207,8 +280,11 @@ class AuthenticationLifecycleTests : BaseTest() assertCleared(fixture) } - @Test fun lateAuth0BrowserCallbackAfterServerSwitchSendsNoToken() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun lateAuth0BrowserCallbackAfterServerSwitchSendsNoToken() { val fixture = start() showAuth0(fixture) val callback = requireNotNull(fixture.callback.get()) @@ -220,8 +296,11 @@ class AuthenticationLifecycleTests : BaseTest() assertCleared(fixture) } - @Test fun delayedAuth0ExchangeAfterLogoutIsRevokedAtOriginalServer() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun delayedAuth0ExchangeAfterLogoutIsRevokedAtOriginalServer() { val fixture = start() showAuth0(fixture) requireNotNull(fixture.callback.get()).onSuccess(credentials()) @@ -234,8 +313,11 @@ class AuthenticationLifecycleTests : BaseTest() assertEquals(first.baseUrl to "synthetic-session-1", fixture.discarded.single()) } - @Test fun duplicateAuth0SuccessCallbackStartsOnlyOneExchange() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun duplicateAuth0SuccessCallbackStartsOnlyOneExchange() { val fixture = start() showAuth0(fixture) val callback = requireNotNull(fixture.callback.get()) @@ -251,8 +333,11 @@ class AuthenticationLifecycleTests : BaseTest() assertTrue(fixture.discarded.isEmpty()) } - @Test fun failedProviderLogoutStillClearsLocalSessionAndCannotRestoreOnRestart() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun failedProviderLogoutStillClearsLocalSessionAndCannotRestoreOnRestart() { val fixture = start(holdFirst = false) ui.runOnIdle { fixture.model.loginWithPassword("operator", "synthetic-password") } ui.waitUntil(5000) { fixture.model.isAuthenticated && !fixture.store.preferences[SESSION_UUID_KEY].isNullOrBlank() } @@ -280,8 +365,11 @@ class AuthenticationLifecycleTests : BaseTest() } } - @Test fun auth0EffectLaunchesOnceAndCurrentResponseIsAdopted() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun auth0EffectLaunchesOnceAndCurrentResponseIsAdopted() { val fixture = start(holdFirst = false) val redraw = mutableStateOf(0) ui.setContent { @@ -300,8 +388,11 @@ class AuthenticationLifecycleTests : BaseTest() assertTrue(fixture.discarded.isEmpty()) } - @Test fun repeatedLogoutSharesOneRevocationAndCannotClearTheNextLogin() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun repeatedLogoutSharesOneRevocationAndCannotClearTheNextLogin() { val fixture = start(holdFirst = false, holdLogout = true) ui.runOnIdle { fixture.model.loginWithPassword("first", "synthetic-password") } ui.waitUntil(5000) { fixture.model.isAuthenticated } @@ -311,9 +402,15 @@ class AuthenticationLifecycleTests : BaseTest() assertSame(firstLogout, repeatedLogout) fixture.model.loginWithPassword("blocked", "synthetic-password") assertTrue(fixture.model.logoutInProgress) - assertNull(fixture.model.prepareOAuth2Login(OAuth2LoginBinding(first.baseUrl, "provider", - "https://identity.example.test/", "https://identity.example.test/discovery", "public-client", - "openid", "qqq-test://callback", "login", "REQUIRED"))) + assertNull( + fixture.model.prepareOAuth2Login( + OAuth2LoginBinding( + first.baseUrl, "provider", + "https://identity.example.test/", "https://identity.example.test/discovery", "public-client", + "openid", "qqq-test://callback", "login", "REQUIRED" + ) + ) + ) } ui.waitUntil(5000) { fixture.logoutCalls.get() == 1 } assertEquals(1, fixture.calls.get()) @@ -329,8 +426,11 @@ class AuthenticationLifecycleTests : BaseTest() } } - @Test fun fullAuthenticationWrapperKeepsLogoutStableWhileStorageFinishes() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun fullAuthenticationWrapperKeepsLogoutStableWhileStorageFinishes() { val fixture = start(holdFirst = false, holdFinalWrite = true) val visible = mutableStateOf(true) ui.runOnIdle { fixture.model.loginWithPassword("operator", "synthetic-password") } @@ -338,7 +438,7 @@ class AuthenticationLifecycleTests : BaseTest() ui.runOnIdle { fixture.model.requestLogout() } ui.setContent { CompositionLocalProvider(LocalAuth0Browser provides fixture.browser) { - if(visible.value) QAuthenticationWrapper(fixture.model) + if (visible.value) QAuthenticationWrapper(fixture.model) } } ui.waitUntil(5000) { fixture.store.writeStarted.isCompleted } diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/BaseTest.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/BaseTest.kt index 350023b..64227ff 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/BaseTest.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/BaseTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -24,18 +25,16 @@ import com.kingsrook.qqq.frontend.android.core.model.Environment import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer import org.junit.Before -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -open class BaseTest -{ + *******************************************************************************/ +open class BaseTest { - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Before - fun beforeEach() - { + fun beforeEach() { QAppContainer.availableEnvironments = listOf( Environment("Test Env 1", "http://192.168.4.67:8000/"), Environment("Test Env 2", "http://192.168.4.67:8000/"), diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/CoilRenderingTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/CoilRenderingTests.kt index 657c1cc..3586873 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/CoilRenderingTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/CoilRenderingTests.kt @@ -38,10 +38,12 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -/** Decode and draw local bytes through the production widget, including its configured SVG decoder. */ -class CoilRenderingTests : BaseTest() -{ - @get:Rule val ui = createComposeRule() +/******************************************************************************* + ** Decode and draw local bytes through the production widget, including its configured SVG decoder. + *******************************************************************************/ +class CoilRenderingTests : BaseTest() { + @get:Rule + val ui = createComposeRule() private val files = mutableListOf() private val models = ViewModelStore() private val visible = mutableStateOf(true) @@ -50,39 +52,45 @@ class CoilRenderingTests : BaseTest() private val left = Color(0xFFC92B1F) private val right = Color(0xFF04AACC) - @After fun disposeFixtures() - { - if(contentInstalled) - { + /******************************************************************************* + ** + *******************************************************************************/ + @After + fun disposeFixtures() { + if (contentInstalled) { ui.runOnIdle { visible.value = false; models.clear() } ui.waitForIdle() } files.forEach { it.delete() } } - @Test fun rasterBytesDecodeAndRenderAtSemanticDimensions() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun rasterBytesDecodeAndRenderAtSemanticDimensions() { val png = fixture(".png") val bitmap = Bitmap.createBitmap(32, 16, Bitmap.Config.ARGB_8888) - try - { - for(y in 0 until bitmap.height) - { - for(x in 0 until bitmap.width) - { - bitmap.setPixel(x, y, if(x < 16) 0xFFC92B1F.toInt() else 0xFF04AACC.toInt()) + try { + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + bitmap.setPixel(x, y, if (x < 16) 0xFFC92B1F.toInt() else 0xFF04AACC.toInt()) } } png.outputStream().use { assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) } + } finally { + bitmap.recycle() } - finally { bitmap.recycle() } render(png) assertDecodedRegions() } - @Test fun svgBytesRenderAndInvalidSourceShowsTheProductionErrorPainter() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun svgBytesRenderAndInvalidSourceShowsTheProductionErrorPainter() { val svg = fixture(".svg").apply { writeText("""""") } @@ -102,16 +110,20 @@ class CoilRenderingTests : BaseTest() val expected = pixels("error-reference") assertEquals(expected.width, actual.width) assertEquals(expected.height, actual.height) - assertTrue("Invalid bytes must replace the previous SVG with the configured error drawable", + assertTrue( + "Invalid bytes must replace the previous SVG with the configured error drawable", samplePoints(actual).all { (x, y) -> sameColor(actual[x, y], expected[x, y]) }) } - private fun render(file: File) - { - val repository = object : QQQRepository by SampleAppMockQQQRepository() - { - override fun getURI(path: String): String - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun render(file: File) { + val repository = object : QQQRepository by SampleAppMockQQQRepository() { + /******************************************************************************* + ** + *******************************************************************************/ + override fun getURI(path: String): String { check(path.startsWith("file://")) { "Image qualification accepts only its private local fixtures" } return path } @@ -125,26 +137,34 @@ class CoilRenderingTests : BaseTest() } ui.setContent() { - if(visible.value) - { + if (visible.value) { Column() { Box(Modifier.testTag("production-image")) { - ImageWidgetBlock(WidgetBlockParameters( - WidgetBlock("image", "IMAGE", styles = mutableMapOf("width" to 64, "height" to 32), - values = mutableMapOf("path" to path.value)), qViewModel = model)) + ImageWidgetBlock( + WidgetBlockParameters( + WidgetBlock( + "image", "IMAGE", styles = mutableMapOf("width" to 64, "height" to 32), + values = mutableMapOf("path" to path.value) + ), qViewModel = model + ) + ) } - Image(painterResource(R.drawable.ic_broken_image), contentDescription = null, - contentScale = ContentScale.Crop, modifier = Modifier.size(64.dp, 32.dp).testTag("error-reference")) + Image( + painterResource(R.drawable.ic_broken_image), contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier.size(64.dp, 32.dp).testTag("error-reference") + ) } } } contentInstalled = true } - private fun assertDecodedRegions() - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun assertDecodedRegions() { ui.onNodeWithTag("production-image").assertWidthIsEqualTo(64.dp).assertHeightIsEqualTo(32.dp) ui.waitUntil(10000) { @@ -153,20 +173,34 @@ class CoilRenderingTests : BaseTest() sameColor(rendered[rendered.width * 3 / 4, rendered.height / 2], right) } val rendered = pixels("production-image") - assertTrue("Decoded fixture must render both distinct source regions", + assertTrue( + "Decoded fixture must render both distinct source regions", sameColor(rendered[rendered.width / 4, rendered.height / 2], left) && - sameColor(rendered[rendered.width * 3 / 4, rendered.height / 2], right)) + sameColor(rendered[rendered.width * 3 / 4, rendered.height / 2], right) + ) } + /******************************************************************************* + ** + *******************************************************************************/ private fun fixture(suffix: String): File = File.createTempFile("qqq-coil-fixture-", suffix, InstrumentationRegistry.getInstrumentation().targetContext.cacheDir) .also { files.add(it) } + /******************************************************************************* + ** + *******************************************************************************/ private fun pixels(tag: String): PixelMap = ui.onNodeWithTag(tag).captureToImage().toPixelMap() + /******************************************************************************* + ** + *******************************************************************************/ private fun samplePoints(image: PixelMap): List> = (1..7).flatMap { x -> (1..7).map { y -> image.width * x / 8 to image.height * y / 8 } } + /******************************************************************************* + ** + *******************************************************************************/ private fun sameColor(actual: Color, expected: Color): Boolean = abs(actual.red - expected.red) < 0.025f && abs(actual.green - expected.green) < 0.025f && abs(actual.blue - expected.blue) < 0.025f && abs(actual.alpha - expected.alpha) < 0.025f diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/NavigationTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/NavigationTests.kt index f1c6b2a..24f9aa5 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/NavigationTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/NavigationTests.kt @@ -22,7 +22,14 @@ import androidx.test.platform.app.InstrumentationRegistry import com.kingsrook.qqq.frontend.android.mobileapp.QMobileAppMain import com.kingsrook.qqq.sampleandroidmobileapp.utils.TestUtils import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository -import com.kingsrook.qqq.frontend.android.core.model.metadata.* +import com.kingsrook.qqq.frontend.android.core.model.metadata.QAppMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QComponentType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendStepMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance +import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData import com.kingsrook.qqq.frontend.android.core.model.processes.QJobComplete import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessAcknowledgment @@ -34,20 +41,22 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -class NavigationTests : BaseTest() -{ - @get:Rule val ui = createAndroidComposeRule() +/******************************************************************************* + ** + *******************************************************************************/ +class NavigationTests : BaseTest() { + @get:Rule + val ui = createAndroidComposeRule() - private fun hardwareBack() - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun hardwareBack() { ui.waitForIdle() // Dialog dismissal is asynchronous at the Android window layer, even after Compose settles. - try - { + try { ui.waitUntil(5000) { ui.activity.hasWindowFocus() } - } - catch(error: androidx.compose.ui.test.ComposeTimeoutException) - { + } catch (error: androidx.compose.ui.test.ComposeTimeoutException) { var windowState = "unavailable" // Collect only state flags on the main thread; never include UI text or device/account identifiers. runCatching { @@ -58,8 +67,7 @@ class NavigationTests : BaseTest() "deviceLocked=${keyguard.isDeviceLocked}, activityFocus=${activity.hasWindowFocus()}, " + "decorFocus=${activity.window.decorView.hasWindowFocus()}, lifecycle=${activity.lifecycle.currentState}, " + "finishing=${activity.isFinishing}, destroyed=${activity.isDestroyed}" - if(android.os.Build.VERSION.SDK_INT >= 29) - { + if (android.os.Build.VERSION.SDK_INT >= 29) { val windows = android.view.inspector.WindowInspector.getGlobalWindowViews() windowState += ", ownWindows=" + windows.take(8).map { view -> val attributes = view.layoutParams as? android.view.WindowManager.LayoutParams @@ -70,23 +78,28 @@ class NavigationTests : BaseTest() } } val networkDialog = runCatching { - if(ui.onAllNodesWithText("No Network Connection").fetchSemanticsNodes().isEmpty()) false - else { ui.onNodeWithText("No Network Connection").assertIsDisplayed(); true } + if (ui.onAllNodesWithText("No Network Connection").fetchSemanticsNodes().isEmpty()) false + else { + ui.onNodeWithText("No Network Connection").assertIsDisplayed(); true + } }.getOrNull() val focusedOwner = runCatching { val instrumentation = InstrumentationRegistry.getInstrumentation() val active = instrumentation.uiAutomation.windows.firstOrNull { it.isFocused }?.root - try - { - when(active?.packageName?.toString()) - { + try { + when (active?.packageName?.toString()) { null -> "unknown" instrumentation.targetContext.packageName -> "own-app" "android", "com.android.systemui" -> "system" else -> "other-app" } + } finally { + // Android 26–32 still pool accessibility nodes; recycling is obsolete from 33. + if (android.os.Build.VERSION.SDK_INT < 33) { + @Suppress("DEPRECATION") + active?.recycle() + } } - finally { active?.recycle() } }.getOrDefault("unavailable") windowState += ", focusedWindowOwner=$focusedOwner, networkDialogVisible=$networkDialog" throw AssertionError("Back requires a focused Activity: $windowState", error) @@ -95,8 +108,11 @@ class NavigationTests : BaseTest() ui.waitForIdle() } - @Test fun signedOutDrawerExposesSettingsWithoutBusinessNavigation() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun signedOutDrawerExposesSettingsWithoutBusinessNavigation() { TestUtils.startStandardTest(ui) ui.onNodeWithTag("topBar.menuIcon").performClick() ui.onNodeWithTag("drawer.home").assertDoesNotExist() @@ -111,35 +127,63 @@ class NavigationTests : BaseTest() ui.onNodeWithTag("topBar.switchApp").assertDoesNotExist() } - @Test fun leavingSwitchingSettingsAndSignOutPreserveOrCancelExactlyOnce() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun leavingSwitchingSettingsAndSignOutPreserveOrCancelExactlyOnce() { val cancellations = AtomicInteger() val submissions = AtomicInteger() val base = SampleAppMockQQQRepository() - val repository = object : QQQRepository by base - { - override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance - { + val repository = object : QQQRepository by base { + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance { val instance = base.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion) return instance.copy(apps = instance.apps.orEmpty() + ("app2" to QAppMetaData("app2", "App 2"))) } - override suspend fun getProcessMetaData(processName: String) = QProcessMetaData("process1", "Process 1", frontendSteps = listOf( - QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), - formFields = listOf(QFieldMetaData("reference", "Reference", type = QFieldType.STRING))))) + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun getProcessMetaData(processName: String) = QProcessMetaData( + "process1", "Process 1", frontendSteps = listOf( + QFrontendStepMetaData( + "input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), + formFields = listOf(QFieldMetaData("reference", "Reference", type = QFieldType.STRING)) + ) + ) + ) + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?) = QJobComplete("navigation-process", nextStep = "input") - override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): QJobComplete - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): QJobComplete { submissions.incrementAndGet() return QJobComplete(processUUID) } - override suspend fun processCancel(processName: String, processUUID: String) { cancellations.incrementAndGet() } + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processCancel(processName: String, processUUID: String) { + cancellations.incrementAndGet() + } } val model = QViewModel(repository, MockPreferencesDataStore()) TestUtils.startStandardTest(ui, model) TestUtils.login(ui) - fun openProcess(fromCatalog: Boolean = true) - { - if(fromCatalog) ui.onNodeWithTag("appHome.rowForApp:app1").performClick() + /******************************************************************************* + ** + *******************************************************************************/ + fun openProcess(fromCatalog: Boolean = true) { + if (fromCatalog) ui.onNodeWithTag("appHome.rowForApp:app1").performClick() ui.onNodeWithTag("appHome.rowForProcess:process1").performClick() ui.waitUntil(5000) { model.activeProcessViewModel?.activeStepName == "input" } } @@ -208,24 +252,44 @@ class NavigationTests : BaseTest() assertNull(model.activeProcessViewModel) } - @Test fun unconfirmedScanCanStayOrExplicitlyLeaveWithoutResubmitting() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun unconfirmedScanCanStayOrExplicitlyLeaveWithoutResubmitting() { val cancellations = AtomicInteger() val submissions = AtomicInteger() val base = SampleAppMockQQQRepository() - val repository = object : QQQRepository by base - { - override suspend fun getProcessMetaData(processName: String) = QProcessMetaData("process1", "Process 1", frontendSteps = listOf( - QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), - formFields = listOf(QFieldMetaData("reference", "Reference", type = QFieldType.STRING))))) + val repository = object : QQQRepository by base { + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun getProcessMetaData(processName: String) = QProcessMetaData( + "process1", "Process 1", frontendSteps = listOf( + QFrontendStepMetaData( + "input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), + formFields = listOf(QFieldMetaData("reference", "Reference", type = QFieldType.STRING)) + ) + ) + ) + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?) = QJobComplete("scan-leave-process", nextStep = "input") - override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): QJobComplete - { - if(submissions.incrementAndGet() == 1) throw java.io.IOException("Synthetic response loss") + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): QJobComplete { + if (submissions.incrementAndGet() == 1) throw java.io.IOException("Synthetic response loss") return QJobComplete(processUUID, values = mapOf("acknowledgedEventId" to "wrong-event"), nextStep = "input") } - override suspend fun processCancel(processName: String, processUUID: String) - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processCancel(processName: String, processUUID: String) { cancellations.incrementAndGet() throw java.io.IOException("Synthetic cancellation unavailable") } @@ -274,8 +338,11 @@ class NavigationTests : BaseTest() assertNull(process.pendingStepValues) } - @Test fun systemBackAndToolbarBackUseTheActualDestination() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun systemBackAndToolbarBackUseTheActualDestination() { TestUtils.startStandardTest(ui) TestUtils.login(ui) val navigator = TestUtils.qViewModel.qNavigator @@ -293,8 +360,11 @@ class NavigationTests : BaseTest() assertTrue(navigator.atHome) } - @Test fun replacingControllerDropsStaleAppAndProcessState() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun replacingControllerDropsStaleAppAndProcessState() { val model = TestUtils.createQViewModel() TestUtils.qViewModel = model val generation = mutableIntStateOf(0) diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/OAuth2LoginTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/OAuth2LoginTests.kt index 1bfccf1..cbfdafa 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/OAuth2LoginTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/OAuth2LoginTests.kt @@ -46,46 +46,73 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -/** Native UI and actual AppAuth request/result handling; no live identity provider or TLS bypass. */ -class OAuth2LoginTests : BaseTest() -{ - @get:Rule val ui = createAndroidComposeRule() +/******************************************************************************* + ** Native UI and actual AppAuth request/result handling; no live identity provider or TLS bypass. + *******************************************************************************/ +class OAuth2LoginTests : BaseTest() { + @get:Rule + val ui = createAndroidComposeRule() private val instrumentation get() = InstrumentationRegistry.getInstrumentation() private val issuer = "https://identity.example.test" - private class Fixture(val model: QViewModel, val checks: AtomicInteger, val exchanges: AtomicInteger, + /******************************************************************************* + ** + *******************************************************************************/ + private class Fixture( + val model: QViewModel, val checks: AtomicInteger, val exchanges: AtomicInteger, val adopted: AtomicReference, val releaseExchange: CompletableDeferred, - val discarded: AtomicReference, val originalNonce: AtomicReference) + val discarded: AtomicReference, val originalNonce: AtomicReference + ) - private fun start(configured: Boolean = true, discoveryIssuer: String = issuer, holdExchange: Boolean = false, - apiVersion: String = "1.10.0", nonceFeature: Boolean = true, noncePolicy: String = "REQUIRED"): Fixture - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun start( + configured: Boolean = true, discoveryIssuer: String = issuer, holdExchange: Boolean = false, + apiVersion: String = "1.10.0", nonceFeature: Boolean = true, noncePolicy: String = "REQUIRED" + ): Fixture { QAppContainer.availableEnvironments = listOf(Environment("Organization", "https://qqq.example.test/")) val callback = oauth2RedirectUri(instrumentation.targetContext.packageName) - val metadata = OAuth2AuthenticationMetaData("organization", values = OAuth2AuthenticationMetaData.Values( - clientId = "public-configuration-id", scopes = "openid profile", - nativeSessionLogin = if(configured) OAuth2AuthenticationMetaData.NativeSessionLogin( - OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", issuer, "$issuer/discovery", listOf(callback), - prompt = "login", nonceValidation = noncePolicy) else null)) + val metadata = OAuth2AuthenticationMetaData( + "organization", values = OAuth2AuthenticationMetaData.Values( + clientId = "public-configuration-id", scopes = "openid profile", + nativeSessionLogin = if (configured) OAuth2AuthenticationMetaData.NativeSessionLogin( + OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", issuer, "$issuer/discovery", listOf(callback), + prompt = "login", nonceValidation = noncePolicy + ) else null + ) + ) val checks = AtomicInteger() val exchanges = AtomicInteger() val adopted = AtomicReference() val discarded = AtomicReference() val originalNonce = AtomicReference() val releaseExchange = CompletableDeferred() - if(!holdExchange) releaseExchange.complete(Unit) + if (!holdExchange) releaseExchange.complete(Unit) val base = SampleAppMockQQQRepository() - val repository = object : QQQRepository by base - { + val repository = object : QQQRepository by base { + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun getAuthenticationMetaData() = metadata - override suspend fun verifyNativeSessionLogin() - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun verifyNativeSessionLogin() { checks.incrementAndGet() - UiApiCompatibility.validateNativeSessionLogin(UiApiVersion(apiVersion, - listOf("nativeSessionLogin") + if(nonceFeature) listOf("nativeSessionNonceValidation") else emptyList())) + UiApiCompatibility.validateNativeSessionLogin( + UiApiVersion( + apiVersion, + listOf("nativeSessionLogin") + if (nonceFeature) listOf("nativeSessionNonceValidation") else emptyList() + ) + ) } - override suspend fun exchangeNativeSession(body: ManageSessionRequest): ManageSessionResponse - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun exchangeNativeSession(body: ManageSessionRequest): ManageSessionResponse { assertEquals(OAuth2AuthenticationMetaData.SESSION_FLOW, body.authenticationFlow) assertEquals(callback, body.redirectUri) assertEquals("synthetic-authorization-code", body.code) @@ -97,14 +124,27 @@ class OAuth2LoginTests : BaseTest() withContext(NonCancellable) { releaseExchange.await() } return ManageSessionResponse("synthetic-native-session") } - override suspend fun discardNativeSession(server: String, session: String) - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun discardNativeSession(server: String, session: String) { assertEquals("https://qqq.example.test/", server) discarded.set(session) } + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Native OAuth must use the deferred semantic exchange") - override fun setSessionUUID(sessionUUID: String) { adopted.set(sessionUUID) } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun setSessionUUID(sessionUUID: String) { + adopted.set(sessionUUID) + } } val model = QViewModel(repository, MockPreferencesDataStore()) val discovery = JSONObject().put("issuer", discoveryIssuer) @@ -123,27 +163,38 @@ class OAuth2LoginTests : BaseTest() return Fixture(model, checks, exchanges, adopted, releaseExchange, discarded, originalNonce) } - private fun browser(result: (AuthorizationRequest) -> Instrumentation.ActivityResult, - block: (AtomicInteger) -> Unit) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun browser( + result: (AuthorizationRequest) -> Instrumentation.ActivityResult, + block: (AtomicInteger) -> Unit + ) { val launches = AtomicInteger() - val monitor = object : Instrumentation.ActivityMonitor() - { - override fun onStartActivity(intent: Intent): Instrumentation.ActivityResult? - { - if(intent.component?.className != "net.openid.appauth.AuthorizationManagementActivity") return null + val monitor = object : Instrumentation.ActivityMonitor() { + /******************************************************************************* + ** + *******************************************************************************/ + override fun onStartActivity(intent: Intent): Instrumentation.ActivityResult? { + if (intent.component?.className != "net.openid.appauth.AuthorizationManagementActivity") return null launches.incrementAndGet() // Pinned AppAuth0.11.1 test adapter; no production code relies on this internal extra. return result(AuthorizationRequest.jsonDeserialize(requireNotNull(intent.getStringExtra("authRequest")))) } } instrumentation.addMonitor(monitor) - try { block(launches) } - finally { instrumentation.removeMonitor(monitor) } + try { + block(launches) + } finally { + instrumentation.removeMonitor(monitor) + } } - @Test fun advertisedBrowserFlowExchangesOnceAndOpensApps() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun advertisedBrowserFlowExchangesOnceAndOpensApps() { val fixture = start() browser({ request -> fixture.originalNonce.set(requireNotNull(request.nonce)) @@ -151,8 +202,10 @@ class OAuth2LoginTests : BaseTest() assertEquals("login", request.prompt) assertEquals(listOf("login"), request.toUri().getQueryParameters("prompt")) assertEquals("public-configuration-id", request.clientId) - Instrumentation.ActivityResult(Activity.RESULT_OK, AuthorizationResponse.Builder(request) - .setState(request.state).setAuthorizationCode("synthetic-authorization-code").build().toIntent()) + Instrumentation.ActivityResult( + Activity.RESULT_OK, AuthorizationResponse.Builder(request) + .setState(request.state).setAuthorizationCode("synthetic-authorization-code").build().toIntent() + ) }) { launches -> ui.onNodeWithTag("oauth2.signIn").performClick() ui.waitUntil(10000) { fixture.model.isAuthenticated } @@ -165,8 +218,11 @@ class OAuth2LoginTests : BaseTest() } } - @Test fun browserCancellationDoesNotExchangeOrFallBackToPassword() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun browserCancellationDoesNotExchangeOrFallBackToPassword() { val fixture = start() browser({ Instrumentation.ActivityResult(Activity.RESULT_CANCELED, Intent()) }) { launches -> ui.onNodeWithTag("oauth2.signIn").performClick() @@ -180,12 +236,17 @@ class OAuth2LoginTests : BaseTest() } } - @Test fun mismatchedBrowserStateNeverExchangesTheCode() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun mismatchedBrowserStateNeverExchangesTheCode() { val fixture = start() browser({ request -> - Instrumentation.ActivityResult(Activity.RESULT_OK, AuthorizationResponse.Builder(request) - .setState("unrelated-browser-state").setAuthorizationCode("synthetic-authorization-code").build().toIntent()) + Instrumentation.ActivityResult( + Activity.RESULT_OK, AuthorizationResponse.Builder(request) + .setState("unrelated-browser-state").setAuthorizationCode("synthetic-authorization-code").build().toIntent() + ) }) { launches -> ui.onNodeWithTag("oauth2.signIn").performClick() ui.waitUntil(10000) { fixture.model.oauth2LoginMessage != null } @@ -195,13 +256,18 @@ class OAuth2LoginTests : BaseTest() } } - @Test fun cancelledAttemptCannotBeRevivedByALateSuccessfulBrowserResult() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun cancelledAttemptCannotBeRevivedByALateSuccessfulBrowserResult() { val fixture = start() browser({ request -> fixture.model.cancelOAuth2Login() - Instrumentation.ActivityResult(Activity.RESULT_OK, AuthorizationResponse.Builder(request) - .setState(request.state).setAuthorizationCode("synthetic-authorization-code").build().toIntent()) + Instrumentation.ActivityResult( + Activity.RESULT_OK, AuthorizationResponse.Builder(request) + .setState(request.state).setAuthorizationCode("synthetic-authorization-code").build().toIntent() + ) }) { launches -> ui.onNodeWithTag("oauth2.signIn").performClick() ui.waitUntil(10000) { launches.get() == 1 && fixture.model.oauth2LoginPhase == OAuth2LoginPhase.READY } @@ -211,14 +277,18 @@ class OAuth2LoginTests : BaseTest() } } - @Test fun cancelledExchangeDiscardsLateSessionWithoutAdoptingIt() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun cancelledExchangeDiscardsLateSessionWithoutAdoptingIt() { val fixture = start(holdExchange = true) - try - { + try { browser({ request -> - Instrumentation.ActivityResult(Activity.RESULT_OK, AuthorizationResponse.Builder(request) - .setState(request.state).setAuthorizationCode("synthetic-authorization-code").build().toIntent()) + Instrumentation.ActivityResult( + Activity.RESULT_OK, AuthorizationResponse.Builder(request) + .setState(request.state).setAuthorizationCode("synthetic-authorization-code").build().toIntent() + ) }) { ui.onNodeWithTag("oauth2.signIn").performClick() ui.waitUntil(10000) { fixture.exchanges.get() == 1 } @@ -229,12 +299,16 @@ class OAuth2LoginTests : BaseTest() assertNull(fixture.adopted.get()) assertFalse(fixture.model.isAuthenticated) } + } finally { + fixture.releaseExchange.complete(Unit) } - finally { fixture.releaseExchange.complete(Unit) } } - @Test fun missingNativeAdvertisementDoesNotOfferPasswordOrBrowserLogin() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun missingNativeAdvertisementDoesNotOfferPasswordOrBrowserLogin() { val fixture = start(configured = false) ui.onNodeWithText("This server has not configured compatible native browser sign-in with required nonce validation. Ask your administrator to enable it.").assertIsDisplayed() ui.onNodeWithTag("oauth2.signIn").assertDoesNotExist() @@ -244,8 +318,11 @@ class OAuth2LoginTests : BaseTest() assertEquals(0, fixture.exchanges.get()) } - @Test fun wrongDiscoveryIssuerIsRejectedBeforeBrowserLaunch() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun wrongDiscoveryIssuerIsRejectedBeforeBrowserLaunch() { val fixture = start(discoveryIssuer = "https://different.example.test") browser({ error("Invalid discovery must never launch a browser") }) { launches -> ui.onNodeWithTag("oauth2.signIn").performClick() @@ -256,18 +333,26 @@ class OAuth2LoginTests : BaseTest() } } - @Test fun oldApiCannotLaunchBrowserEvenWithNewMetadata() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun oldApiCannotLaunchBrowserEvenWithNewMetadata() { assertVersionRejected(start(apiVersion = "1.9.0")) } - @Test fun missingNonceFeatureCannotLaunchBrowserEvenWithRequiredDescriptor() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun missingNonceFeatureCannotLaunchBrowserEvenWithRequiredDescriptor() { assertVersionRejected(start(nonceFeature = false)) } - private fun assertVersionRejected(fixture: Fixture) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun assertVersionRejected(fixture: Fixture) { browser({ error("Unsupported nonce contract must not launch the browser") }) { launches -> ui.onNodeWithTag("oauth2.signIn").performClick() ui.waitUntil(10000) { fixture.model.oauth2LoginMessage != null } @@ -279,8 +364,11 @@ class OAuth2LoginTests : BaseTest() } } - @Test fun missingNoncePolicyDoesNotOfferBrowserOrPasswordFallback() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun missingNoncePolicyDoesNotOfferBrowserOrPasswordFallback() { val fixture = start(noncePolicy = "") ui.onNodeWithText("This server has not configured compatible native browser sign-in with required nonce validation. Ask your administrator to enable it.").assertIsDisplayed() ui.onNodeWithTag("oauth2.signIn").assertDoesNotExist() @@ -289,15 +377,20 @@ class OAuth2LoginTests : BaseTest() assertEquals(0, fixture.exchanges.get()) } - @Test fun callbackRequestCannotReplaceTheRetainedOriginalNonce() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun callbackRequestCannotReplaceTheRetainedOriginalNonce() { val fixture = start() browser({ request -> val changed = AuthorizationRequest.Builder(request.configuration, request.clientId, request.responseType, request.redirectUri) .setState(request.state).setNonce("replacement-nonce-must-fail").setCodeVerifier(requireNotNull(request.codeVerifier)) .setScope(request.scope).setPrompt(request.prompt).build() - Instrumentation.ActivityResult(Activity.RESULT_OK, AuthorizationResponse.Builder(changed) - .setState(request.state).setAuthorizationCode("synthetic-authorization-code").build().toIntent()) + Instrumentation.ActivityResult( + Activity.RESULT_OK, AuthorizationResponse.Builder(changed) + .setState(request.state).setAuthorizationCode("synthetic-authorization-code").build().toIntent() + ) }) { launches -> ui.onNodeWithTag("oauth2.signIn").performClick() ui.waitUntil(10000) { fixture.model.oauth2LoginMessage != null } diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/OAuth2LogoutTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/OAuth2LogoutTests.kt index e446e49..84906d8 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/OAuth2LogoutTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/OAuth2LogoutTests.kt @@ -16,7 +16,12 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.Nat import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData 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.* +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalOAuth2Discovery +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.QLogoutForm +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.oauth2LogoutBinding +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.oauth2RedirectUri import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import java.util.UUID @@ -28,42 +33,69 @@ import net.openid.appauth.EndSessionRequest import net.openid.appauth.EndSessionResponse import org.json.JSONArray import org.json.JSONObject -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -/** Tests actual SDK request/result plumbing with synthetic responses, not real-provider acceptance. */ -class OAuth2LogoutTests : BaseTest() -{ - @get:Rule val ui = createAndroidComposeRule() +/******************************************************************************* + ** Tests actual SDK request/result plumbing with synthetic responses, not real-provider acceptance. + *******************************************************************************/ +class OAuth2LogoutTests : BaseTest() { + @get:Rule + val ui = createAndroidComposeRule() private val instrumentation get() = InstrumentationRegistry.getInstrumentation() private val issuer = "https://identity.example.test" private val endpoint = "$issuer/end-session" private val hint = "qqq-logout-canary-" + UUID.randomUUID() - private class Fixture(val model: QViewModel, val binding: OAuth2LogoutBinding, val calls: AtomicInteger, - val cookie: AtomicReference, val preferences: MockPreferencesDataStore) + /******************************************************************************* + ** + *******************************************************************************/ + private class Fixture( + val model: QViewModel, val binding: OAuth2LogoutBinding, val calls: AtomicInteger, + val cookie: AtomicReference, val preferences: MockPreferencesDataStore + ) - private fun start(failLocal: Boolean = false, returnedEndpoint: String = endpoint): Fixture - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun start(failLocal: Boolean = false, returnedEndpoint: String = endpoint): Fixture { val server = "https://qqq.example.test/" QAppContainer.availableEnvironments = listOf(Environment("Company", server)) val callback = oauth2RedirectUri(instrumentation.targetContext.packageName) - val metadata = OAuth2AuthenticationMetaData("company", values = OAuth2AuthenticationMetaData.Values( - clientId = "synthetic-client", scopes = "openid", nativeSessionLogin = OAuth2AuthenticationMetaData.NativeSessionLogin( - OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", issuer, "$issuer/discovery", listOf(callback), "login", "REQUIRED"), - nativeSessionLogout = OAuth2AuthenticationMetaData.NativeSessionLogout(OAuth2AuthenticationMetaData.LOGOUT_FLOW, listOf(callback)))) + val metadata = OAuth2AuthenticationMetaData( + "company", values = OAuth2AuthenticationMetaData.Values( + clientId = "synthetic-client", scopes = "openid", nativeSessionLogin = OAuth2AuthenticationMetaData.NativeSessionLogin( + OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", issuer, "$issuer/discovery", listOf(callback), "login", "REQUIRED" + ), + nativeSessionLogout = OAuth2AuthenticationMetaData.NativeSessionLogout(OAuth2AuthenticationMetaData.LOGOUT_FLOW, listOf(callback)) + ) + ) val calls = AtomicInteger() val cookie = AtomicReference("synthetic-session") - val repository = object : QQQRepository by SampleAppMockQQQRepository() - { + val repository = object : QQQRepository by SampleAppMockQQQRepository() { + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun getAuthenticationMetaData() = metadata - override fun setSessionUUID(sessionUUID: String) { cookie.set(sessionUUID) } - override suspend fun logoutWithProvider(postLogoutRedirectUri: String): NativeLogoutData? - { + + /******************************************************************************* + ** + *******************************************************************************/ + override fun setSessionUUID(sessionUUID: String) { + cookie.set(sessionUUID) + } + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun logoutWithProvider(postLogoutRedirectUri: String): NativeLogoutData? { assertEquals(callback, postLogoutRedirectUri) calls.incrementAndGet() - if(failLocal) error("synthetic transport failure") + if (failLocal) error("synthetic transport failure") return NativeLogoutData(issuer, returnedEndpoint, callback, hint) } } @@ -77,7 +109,7 @@ class OAuth2LogoutTests : BaseTest() CompositionLocalProvider(LocalOAuth2Discovery provides { AuthorizationServiceConfiguration(AuthorizationServiceDiscovery(discovery)) }) { - if(model.isLogoutRequested) QLogoutForm(model, metadata) else Text("Ready") + if (model.isLogoutRequested) QLogoutForm(model, metadata) else Text("Ready") } } ui.waitUntil(10000) { model.initialLoadFromDataStoreLoadState is LoadState.Success && model.authenticationMetaDataLoadState is LoadState.Success } @@ -85,26 +117,34 @@ class OAuth2LogoutTests : BaseTest() return Fixture(model, requireNotNull(oauth2LogoutBinding(metadata, server, instrumentation.targetContext.packageName)), calls, cookie, preferences) } - private fun browser(result: (EndSessionRequest) -> Instrumentation.ActivityResult, block: (AtomicInteger) -> Unit) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun browser(result: (EndSessionRequest) -> Instrumentation.ActivityResult, block: (AtomicInteger) -> Unit) { val launches = AtomicInteger() - val monitor = object : Instrumentation.ActivityMonitor() - { - override fun onStartActivity(intent: Intent): Instrumentation.ActivityResult? - { - if(intent.component?.className != "net.openid.appauth.AuthorizationManagementActivity") return null + val monitor = object : Instrumentation.ActivityMonitor() { + /******************************************************************************* + ** + *******************************************************************************/ + override fun onStartActivity(intent: Intent): Instrumentation.ActivityResult? { + if (intent.component?.className != "net.openid.appauth.AuthorizationManagementActivity") return null launches.incrementAndGet() // Pinned SDK adapter. Production uses the public AppAuth result contract. return result(EndSessionRequest.jsonDeserialize(requireNotNull(intent.getStringExtra("authRequest")))) } } instrumentation.addMonitor(monitor) - try { block(launches) } - finally { instrumentation.removeMonitor(monitor) } + try { + block(launches) + } finally { + instrumentation.removeMonitor(monitor) + } } - private fun beginAndAwait(fixture: Fixture) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun beginAndAwait(fixture: Fixture) { ui.runOnIdle { fixture.model.requestCompanyLogout(fixture.binding) } ui.waitUntil(10000) { fixture.model.companyLogoutPhase == OAuth2LogoutPhase.FINISHED } assertFalse(fixture.model.isAuthenticated) @@ -114,8 +154,11 @@ class OAuth2LogoutTests : BaseTest() assertEquals(1, fixture.calls.get()) } - @Test fun stateOnlyReturnCompletesAfterLocalClearingAndDoesNotLogOrPersistHint() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun stateOnlyReturnCompletesAfterLocalClearingAndDoesNotLogOrPersistHint() { val fixture = start() browser({ request -> assertFalse(fixture.model.isAuthenticated) @@ -138,19 +181,29 @@ class OAuth2LogoutTests : BaseTest() assertFalse("Synthetic logout hint must not enter logcat", logs.contains(hint)) } - @Test fun cancellationAndWrongStateNeverClaimProviderLogoutOrRestoreLocalIdentity() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun cancellationAndWrongStateNeverClaimProviderLogoutOrRestoreLocalIdentity() { val fixture = start() - browser({ request -> Instrumentation.ActivityResult(Activity.RESULT_OK, - EndSessionResponse.Builder(request).setState("wrong-state").build().toIntent()) }) { launches -> + browser({ request -> + Instrumentation.ActivityResult( + Activity.RESULT_OK, + EndSessionResponse.Builder(request).setState("wrong-state").build().toIntent() + ) + }) { launches -> beginAndAwait(fixture) assertTrue(fixture.model.logoutMessage.contains("Company sign-out did not complete.")) assertEquals(1, launches.get()) } } - @Test fun cancelledBrowserLeavesLocalSessionCleared() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun cancelledBrowserLeavesLocalSessionCleared() { val fixture = start() browser({ Instrumentation.ActivityResult(Activity.RESULT_CANCELED, Intent()) }) { launches -> beginAndAwait(fixture) @@ -159,8 +212,11 @@ class OAuth2LogoutTests : BaseTest() } } - @Test fun localFailureDoesNotLaunchBrowserAndStillClearsPrivateState() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun localFailureDoesNotLaunchBrowserAndStillClearsPrivateState() { val fixture = start(failLocal = true) browser({ error("Browser must not launch after local failure") }) { launches -> beginAndAwait(fixture) @@ -169,8 +225,11 @@ class OAuth2LogoutTests : BaseTest() } } - @Test fun mismatchedDiscoveredEndpointCannotReceiveHint() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun mismatchedDiscoveredEndpointCannotReceiveHint() { val fixture = start(returnedEndpoint = "https://other.example.test/logout") browser({ error("Untrusted endpoint must not receive the hint") }) { launches -> beginAndAwait(fixture) @@ -179,8 +238,11 @@ class OAuth2LogoutTests : BaseTest() } } - @Test fun lateCallbackAfterResetCannotRestoreLogoutOrLoginState() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun lateCallbackAfterResetCannotRestoreLogoutOrLoginState() { val fixture = start() browser({ request -> fixture.model.resetApp() @@ -195,11 +257,18 @@ class OAuth2LogoutTests : BaseTest() } } - @Test fun wrongAppAuthResponseTypeCannotCompleteLogout() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun wrongAppAuthResponseTypeCannotCompleteLogout() { val fixture = start() - browser({ Instrumentation.ActivityResult(Activity.RESULT_OK, - Intent().putExtra("net.openid.appauth.AuthorizationResponse", "{}")) }) { launches -> + browser({ + Instrumentation.ActivityResult( + Activity.RESULT_OK, + Intent().putExtra("net.openid.appauth.AuthorizationResponse", "{}") + ) + }) { launches -> beginAndAwait(fixture) assertTrue(fixture.model.logoutMessage.contains("Company sign-out did not complete.")) assertEquals(1, launches.get()) diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/ProcessTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/ProcessTests.kt index e77a95a..ced6d10 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/ProcessTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/ProcessTests.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -32,28 +33,25 @@ import com.kingsrook.qqq.sampleandroidmobileapp.utils.clickAppScreenOption import org.junit.Rule import org.junit.Test -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class ProcessTests : BaseTest() -{ + *******************************************************************************/ +class ProcessTests : BaseTest() { @get:Rule val composeTestRule = createComposeRule() - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - private fun atEndLocalDebugSleep() - { + *******************************************************************************/ + private fun atEndLocalDebugSleep() { TestUtils.localDebugSleep(composeTestRule, 5) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testLaunchProcess() - { + fun testLaunchProcess() { TestUtils.startStandardTest(composeTestRule) TestUtils.login(composeTestRule) @@ -76,4 +74,4 @@ class ProcessTests : BaseTest() atEndLocalDebugSleep() } -} \ No newline at end of file +} diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleAppMockQQQRepository.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleAppMockQQQRepository.kt index 19a77b4..380eccf 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleAppMockQQQRepository.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleAppMockQQQRepository.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -39,11 +40,10 @@ import com.kingsrook.qqq.frontend.android.core.model.processes.QJobComplete import com.kingsrook.qqq.frontend.android.core.model.processes.QJobStarted import java.util.UUID -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class SampleAppMockQQQRepository : QQQRepository -{ + *******************************************************************************/ +class SampleAppMockQQQRepository : QQQRepository { private var sessionUUID: String? = null private var environment: Environment = Environment("Mock Env", "https://mock/") @@ -59,51 +59,44 @@ class SampleAppMockQQQRepository : QQQRepository "process1" to QProcessMetaData(name = "process1", label = "Process 1") ) - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override fun setEnvironment(environment: Environment) - { + *******************************************************************************/ + override fun setEnvironment(environment: Environment) { this.environment = environment } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override fun setSessionUUID(sessionUUID: String) - { + *******************************************************************************/ + override fun setSessionUUID(sessionUUID: String) { this.sessionUUID = sessionUUID } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData - { + *******************************************************************************/ + override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData { return (FullyAnonymousAuthenticationMetaData("FULLY_ANONYMOUS")) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse - { + *******************************************************************************/ + override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse { return ManageSessionResponse(UUID.randomUUID().toString()) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance - { - if((sessionUUID ?: "").lowercase().contains("fail")) - { + *******************************************************************************/ + override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance { + if ((sessionUUID ?: "").lowercase().contains("fail")) { throw RuntimeException("sessionUUID indicates that we should fail") } var appName = "Mock Application" - if(environment.label == "Test Env 2") - { + if (environment.label == "Test Env 2") { appName += " 2" } @@ -119,63 +112,57 @@ class SampleAppMockQQQRepository : QQQRepository return QInstance( branding = QBrandingMetaData(appName = appName), - appTree = if(environment.label == "Test Env 2") env2AppTree else env1AppTree, + appTree = if (environment.label == "Test Env 2") env2AppTree else env1AppTree, apps = appMap, processes = processMap ) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override suspend fun getProcessMetaData(processName: String): QProcessMetaData - { + *******************************************************************************/ + override suspend fun getProcessMetaData(processName: String): QProcessMetaData { val processMetaData = processMap[processName] - if(processMetaData != null) - { + if (processMetaData != null) { return processMetaData } return QProcessMetaData(name = processName, label = processName) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult - { + *******************************************************************************/ + override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult { return QJobComplete(UUID.randomUUID().toString(), emptyMap(), "step2") } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult - { - // return QJobComplete(UUID.randomUUID().toString(), emptyMap(), "step2") + *******************************************************************************/ + override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult { return QJobStarted(UUID.randomUUID().toString(), UUID.randomUUID().toString()) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult - { + *******************************************************************************/ + override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult { return QJobComplete(UUID.randomUUID().toString(), emptyMap(), "step2") } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override suspend fun processCancel(processName: String, processUUID: String) - { + *******************************************************************************/ + override suspend fun processCancel(processName: String, processUUID: String) { } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - override fun getURI(path: String): String - { - TODO("Not yet implemented") + *******************************************************************************/ + override fun getURI(path: String): String { + val uri = java.net.URI(environment.baseUrl).resolve(path) + require(uri.scheme in setOf("https", "http") && uri.userInfo == null && uri.host != null) { "Unsupported media URL" } + return uri.toASCIIString() } } diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleHostSecurityTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleHostSecurityTests.kt index b799c45..aebe941 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleHostSecurityTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleHostSecurityTests.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2026. Kingsrook, LLC * Licensed under the Apache License, Version 2.0 (the "License"); @@ -26,9 +27,14 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +/******************************************************************************* + ** + *******************************************************************************/ @RunWith(AndroidJUnit4::class) -class SampleHostSecurityTests -{ +class SampleHostSecurityTests { + /******************************************************************************* + ** + *******************************************************************************/ @Test fun sessionStorageIsExcludedFromBackup() = runBlocking() { @@ -36,14 +42,11 @@ class SampleHostSecurityTests assertFalse(context.applicationInfo.flags and ApplicationInfo.FLAG_ALLOW_BACKUP != 0) assertEquals(BuildConfig.DEBUG, NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) val settings = SettingsRepository(context.dataStore) - try - { + try { settings.setSessionUUID("synthetic-backup-exclusion-test") assertEquals("synthetic-backup-exclusion-test", settings.getSessionUUID()) assertTrue(File(context.noBackupFilesDir, "settings.preferences_pb").isFile) - } - finally - { + } finally { settings.setSessionUUID("") } } diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SemanticInputTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SemanticInputTests.kt index 5f37264..ad68060 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SemanticInputTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SemanticInputTests.kt @@ -47,19 +47,24 @@ import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test -/** Exercise real native controls in a constrained viewport, including the actions below their content. */ -class SemanticInputTests : BaseTest() -{ - @get:Rule val ui = createComposeRule() +/******************************************************************************* + ** Exercise real native controls in a constrained viewport, including the actions below their content. + *******************************************************************************/ +class SemanticInputTests : BaseTest() { + @get:Rule + val ui = createComposeRule() - /** A short screen must still let an operator reach Sign in and actually call authentication. */ - @Test fun loginIsReachableInAShortViewport() - { + /******************************************************************************* + ** A short screen must still let an operator reach Sign in and actually call authentication. + *******************************************************************************/ + @Test + fun loginIsReachableInAShortViewport() { val loggedIn = AtomicReference() - val repository = object : QQQRepository by SampleAppMockQQQRepository() - { - override suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse - { + val repository = object : QQQRepository by SampleAppMockQQQRepository() { + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse { loggedIn.set(username) return ManageSessionResponse("local-test-session") } @@ -79,18 +84,33 @@ class SemanticInputTests : BaseTest() ui.waitUntil(10000) { loggedIn.get() == "operator" } } - /** The last field scrolls into view while the standard action remains outside the scrolling content. */ - @Test fun longFormCanEditItsLastFieldAndSubmit() - { + /******************************************************************************* + ** The last field scrolls into view while the standard action remains outside the scrolling content. + *******************************************************************************/ + @Test + fun longFormCanEditItsLastFieldAndSubmit() { val saved = AtomicReference?>() - val repository = object : QQQRepository by SampleAppMockQQQRepository() - { - override suspend fun getProcessMetaData(processName: String) = QProcessMetaData("long", "Long form", frontendSteps = listOf( - QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), - formFields = (1..12).map { QFieldMetaData("field$it", "Field $it", type = QFieldType.STRING) }))) + val repository = object : QQQRepository by SampleAppMockQQQRepository() { + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun getProcessMetaData(processName: String) = QProcessMetaData( + "long", "Long form", frontendSteps = listOf( + QFrontendStepMetaData( + "input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), + formFields = (1..12).map { QFieldMetaData("field$it", "Field $it", type = QFieldType.STRING) }) + ) + ) + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?) = QJobComplete("p", nextStep = "input") - override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): QJobComplete - { + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): QJobComplete { saved.set(values) return QJobComplete("p") } @@ -114,13 +134,18 @@ class SemanticInputTests : BaseTest() ui.onNodeWithText("Close").assertIsDisplayed() } - /** Autofocus is presentation metadata: arrow-prefixed data must not become a business action. */ - @Test fun inputWidgetSubmitsArrowPrefixLiterally() - { + /******************************************************************************* + ** Autofocus is presentation metadata: arrow-prefixed data must not become a business action. + *******************************************************************************/ + @Test + fun inputWidgetSubmitsArrowPrefixLiterally() { val submitted = AtomicReference?>() - val block = WidgetBlock("input", "INPUT_FIELD", values = mutableMapOf( - "autoFocus" to true, "submitOnEnter" to true, - "fieldMetaData" to mapOf("name" to "code", "label" to "Code", "type" to "STRING"))) + val block = WidgetBlock( + "input", "INPUT_FIELD", values = mutableMapOf( + "autoFocus" to true, "submitOnEnter" to true, + "fieldMetaData" to mapOf("name" to "code", "label" to "Code", "type" to "STRING") + ) + ) ui.setContent { MaterialTheme { InputFieldWidgetBlock(WidgetBlockParameters(block, { submitted.set(it) })) } } ui.onNodeWithText("Code").performTextReplacement("->literal") ui.onNodeWithText("Code").performImeAction() @@ -128,11 +153,19 @@ class SemanticInputTests : BaseTest() assertEquals(mapOf("code" to "->literal"), submitted.get()) } - /** Field presentation flags apply to widgets just as they do to semantic forms. */ - @Test fun widgetPasswordReadonlyAndHiddenMetadataAreHonored() - { - fun block(name: String, type: String, properties: Map = emptyMap()) = WidgetBlock(name, "INPUT_FIELD", values = mutableMapOf( - "fieldMetaData" to (mapOf("name" to name, "label" to name, "type" to type) + properties))) + /******************************************************************************* + ** Field presentation flags apply to widgets just as they do to semantic forms. + *******************************************************************************/ + @Test + fun widgetPasswordReadonlyAndHiddenMetadataAreHonored() { + /******************************************************************************* + ** + *******************************************************************************/ + fun block(name: String, type: String, properties: Map = emptyMap()) = WidgetBlock( + name, "INPUT_FIELD", values = mutableMapOf( + "fieldMetaData" to (mapOf("name" to name, "label" to name, "type" to type) + properties) + ) + ) ui.setContent { MaterialTheme { Column { diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SessionTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SessionTests.kt index 78e3530..59508f6 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SessionTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SessionTests.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -47,11 +48,10 @@ import org.junit.Assert.assertNotEquals import org.junit.Rule import org.junit.Test -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class SessionTests : BaseTest() -{ + *******************************************************************************/ +class SessionTests : BaseTest() { @get:Rule val composeTestRule = createComposeRule() @@ -59,12 +59,11 @@ class SessionTests : BaseTest() private val fullName = "John Doe Test" private val envLabel = "Some Stored Env" - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testLogin() - { + fun testLogin() { TestUtils.startStandardTest(composeTestRule) TestUtils.login(composeTestRule) @@ -73,12 +72,11 @@ class SessionTests : BaseTest() atEndLocalDebugSleep() } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testValuesFromDataStore() - { + fun testValuesFromDataStore() { val dataStore = makeMockPreferencesDataStoreWithValues() val qViewModel = QViewModel(SampleAppMockQQQRepository(), dataStore) qViewModel.setNavController(null) @@ -110,12 +108,11 @@ class SessionTests : BaseTest() atEndLocalDebugSleep() } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testExpiredSessionFromDataStore() - { + fun testExpiredSessionFromDataStore() { val failureUUID = "please-fail" val dataStore = makeMockPreferencesDataStoreWithValues() (dataStore.preferences as MutablePreferences)[SESSION_UUID_KEY] = failureUUID @@ -139,21 +136,15 @@ class SessionTests : BaseTest() assertNotEquals(failureUUID, qViewModel.sessionUUID) assertNotEquals(failureUUID, dataStore.preferences[SESSION_UUID_KEY]) - // in case the above ever fails, it probably means race condition, so this instead - // composeTestRule.waitUntil(condition = - // { - // failureUUID != dataStore.preferences[SESSION_UUID_KEY] - // }) atEndLocalDebugSleep() } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testUserInfoDialogAndLogout() - { + fun testUserInfoDialogAndLogout() { TestUtils.startStandardTest(composeTestRule) openUserDialog() @@ -207,12 +198,11 @@ class SessionTests : BaseTest() atEndLocalDebugSleep() } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Test - fun testEnvironmentDialogAndSwitch() - { + fun testEnvironmentDialogAndSwitch() { TestUtils.startStandardTest(composeTestRule) TestUtils.login(composeTestRule) @@ -246,11 +236,10 @@ class SessionTests : BaseTest() atEndLocalDebugSleep() } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - private fun makeMockPreferencesDataStoreWithValues(): MockPreferencesDataStore - { + *******************************************************************************/ + private fun makeMockPreferencesDataStoreWithValues(): MockPreferencesDataStore { val dataStore = MockPreferencesDataStore() dataStore.preferences = preferencesOf( SESSION_UUID_KEY to knownUUID, @@ -262,11 +251,10 @@ class SessionTests : BaseTest() return dataStore } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - private fun openUserDialog() - { + *******************************************************************************/ + private fun openUserDialog() { composeTestRule .onNodeWithTag("topBar.accountIcon", true) .assertIsDisplayed() @@ -278,26 +266,24 @@ class SessionTests : BaseTest() .assertIsDisplayed() } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - private fun assertHomePageIsDisplayed(pageTitle: String = "Apps", anAppLabelOnPage: String = "App 1") - { + *******************************************************************************/ + private fun assertHomePageIsDisplayed(pageTitle: String = "Apps", anAppLabelOnPage: String = "App 1") { composeTestRule .onNodeWithTag("topAppBar.title") .assertTextContains(pageTitle) .assertIsDisplayed() composeTestRule - .onNodeWithTag("appHome.rowForApp:" + if(anAppLabelOnPage == "App 3") "app3" else "app1") + .onNodeWithTag("appHome.rowForApp:" + if (anAppLabelOnPage == "App 3") "app3" else "app1") .assertIsDisplayed() } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - private fun atEndLocalDebugSleep() - { + *******************************************************************************/ + private fun atEndLocalDebugSleep() { TestUtils.localDebugSleep(composeTestRule, 0) } diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/apps/QAppHomePreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/apps/QAppHomePreview.kt index fbeeb92..38dc788 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/apps/QAppHomePreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/apps/QAppHomePreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -31,13 +32,12 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData import com.kingsrook.qqq.frontend.android.mobileapp.ui.apps.QAppHome -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 400, heightDp = 800) @Composable -fun QAppHomeTopLevelPreview() -{ +fun QAppHomeTopLevelPreview() { val qInstance = QInstance( appTree = listOf( QAppTreeNode("app1", "App 1", QAppNodeType.APP), @@ -49,13 +49,12 @@ fun QAppHomeTopLevelPreview() QAppHome(qInstance, Modifier, null) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 400, heightDp = 800) @Composable -fun QAppHomeAppLevelPreviewWithWidgets() -{ +fun QAppHomeAppLevelPreviewWithWidgets() { val qInstance = QInstance() val app = QAppMetaData( @@ -70,17 +69,18 @@ fun QAppHomeAppLevelPreviewWithWidgets() QAppHome(qInstance, Modifier, app) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 380, heightDp = 800) @Composable -fun QAppHomeAppLevelPreviewWithProcesses() -{ - val qInstance = QInstance(processes = mapOf( - "process1" to QProcessMetaData("process1", "Process 1"), - "process2" to QProcessMetaData("process2", "Process 2 with the very long and why wont name that wraps nicely") - )) +fun QAppHomeAppLevelPreviewWithProcesses() { + val qInstance = QInstance( + processes = mapOf( + "process1" to QProcessMetaData("process1", "Process 1"), + "process2" to QProcessMetaData("process2", "Process 2 with the very long and why wont name that wraps nicely") + ) + ) val app = QAppMetaData( "app2", "App 2", children = listOf( diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/horseshoe/MenuDialogPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/horseshoe/MenuDialogPreview.kt index 89615f0..011df71 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/horseshoe/MenuDialogPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/horseshoe/MenuDialogPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -28,13 +29,12 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.MenuDialog import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import com.kingsrook.qqq.sampleandroidmobileapp.previews.utils.PreviewUtils -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 400) @Composable -fun MenuDialogPreviewNormal() -{ +fun MenuDialogPreviewNormal() { QViewModel.applicationVersion = "17.47" QViewModel.applicationBuildTimestamp = "2024-11-04 14:36:00 UTC" val qViewModel = PreviewUtils.createQViewModel().also() @@ -47,13 +47,12 @@ fun MenuDialogPreviewNormal() MenuDialog(qViewModel, "A Preview", isOpen) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 400) @Composable -fun MenuDialogPreviewSuperLongName() -{ +fun MenuDialogPreviewSuperLongName() { val qViewModel = PreviewUtils.createQViewModel().also() { it.logInSuccessful() @@ -64,15 +63,14 @@ fun MenuDialogPreviewSuperLongName() MenuDialog(qViewModel, "A Preview", isOpen) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 400) @Composable -fun MenuDialogPreviewNotLoggedIn() -{ +fun MenuDialogPreviewNotLoggedIn() { val qViewModel = PreviewUtils.createQViewModel() val isOpen = remember { mutableStateOf(true) } MenuDialog(qViewModel, "A Preview", isOpen) -} \ No newline at end of file +} diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/horseshoe/QTopAppBarPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/horseshoe/QTopAppBarPreview.kt index 6597fb9..e7b6d3b 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/horseshoe/QTopAppBarPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/horseshoe/QTopAppBarPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -28,13 +29,12 @@ import androidx.compose.ui.tooling.preview.Preview import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.QTopAppBar import com.kingsrook.qqq.sampleandroidmobileapp.previews.utils.PreviewUtils -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 400) @Composable -fun QTopAppBarPreview() -{ +fun QTopAppBarPreview() { val qViewModel = PreviewUtils.createQViewModel().also() { it.logInSuccessful() diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/ProcessStepLoadingPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/ProcessStepLoadingPreview.kt index 1cae61b..3295067 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/ProcessStepLoadingPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/ProcessStepLoadingPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -27,51 +28,47 @@ import com.kingsrook.qqq.sampleandroidmobileapp.SampleAppMockQQQRepository import com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.ProcessStepLoading import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ProcessStepLoadingPreview() -{ +fun ProcessStepLoadingPreview() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() ProcessStepLoading(processViewModel) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ProcessStepLoadingPreviewWithMessageWithoutCurrentAndTotal() -{ +fun ProcessStepLoadingPreviewWithMessageWithoutCurrentAndTotal() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() processViewModel.jobRunningMessage = "Working..." ProcessStepLoading(processViewModel) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ProcessStepLoadingPreviewSuperLongMessage() -{ +fun ProcessStepLoadingPreviewSuperLongMessage() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() processViewModel.jobRunningMessage = "Doing a thing and stuff and words and more and and on and on." ProcessStepLoading(processViewModel) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ProcessStepLoadingPreviewWithMessageWithCurrentAndTotal() -{ +fun ProcessStepLoadingPreviewWithMessageWithCurrentAndTotal() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() processViewModel.jobRunningMessage = "Processing Orders" diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/ProcessComponentHTMLPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/ProcessComponentHTMLPreview.kt index 33c0607..8256d82 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/ProcessComponentHTMLPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/ProcessComponentHTMLPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -29,13 +30,12 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent import com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.components.ProcessComponentHTML import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ProcessComponentHTMLPreview() -{ +fun ProcessComponentHTMLPreview() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() processViewModel.activeStepName = "preview" diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/ProcessComponentWidgetPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/ProcessComponentWidgetPreview.kt index e96db85..6d4846b 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/ProcessComponentWidgetPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/ProcessComponentWidgetPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -30,97 +31,101 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance import com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.components.ProcessComponentWidget import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ProcessComponentWidgetPreview() -{ +fun ProcessComponentWidgetPreview() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() - val component = QFrontendComponent(type = QComponentType.WIDGET, values = mapOf( - "isAdHocWidget" to true, - "blocks" to listOf( - mapOf( - "blockType" to "TEXT", - "styles" to mapOf( - "foo" to "bar" - ), - "values" to mapOf( - "text" to "this is my text, what do you want from me?" - ), + val component = QFrontendComponent( + type = QComponentType.WIDGET, values = mapOf( + "isAdHocWidget" to true, + "blocks" to listOf( + mapOf( + "blockType" to "TEXT", + "styles" to mapOf( + "foo" to "bar" + ), + "values" to mapOf( + "text" to "this is my text, what do you want from me?" + ), + ) ) ) - )) - ProcessComponentWidget(processViewModel, QInstance(), component) + ) + ProcessComponentWidget(processViewModel, QInstance(), component) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ProcessComponentWidgetPreviewMultipleAdHoc() -{ +fun ProcessComponentWidgetPreviewMultipleAdHoc() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() - val component = QFrontendComponent(type = QComponentType.WIDGET, values = mapOf( - "isAdHocWidget" to true, - "blocks" to listOf( - mapOf( - "blockType" to "TEXT", - "styles" to mapOf( - "standardColor" to "SUCCESS" - ), - "values" to mapOf( - "text" to "this is my text, what do you want from me?" - ), - ), - mapOf( - "blockType" to "TEXT", - "values" to mapOf( - "text" to "and this is mine." + val component = QFrontendComponent( + type = QComponentType.WIDGET, values = mapOf( + "isAdHocWidget" to true, + "blocks" to listOf( + mapOf( + "blockType" to "TEXT", + "styles" to mapOf( + "standardColor" to "SUCCESS" + ), + "values" to mapOf( + "text" to "this is my text, what do you want from me?" + ), ), + mapOf( + "blockType" to "TEXT", + "values" to mapOf( + "text" to "and this is mine." + ), + ) ) ) - )) + ) ProcessComponentWidget(processViewModel, QInstance(), component) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ProcessComponentWidgetAdHocUnknownType() -{ +fun ProcessComponentWidgetAdHocUnknownType() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() - val component = QFrontendComponent(type = QComponentType.WIDGET, values = mapOf( - "isAdHocWidget" to true, - "blocks" to listOf( - mapOf( - "blockType" to "FOOBAR" + val component = QFrontendComponent( + type = QComponentType.WIDGET, values = mapOf( + "isAdHocWidget" to true, + "blocks" to listOf( + mapOf( + "blockType" to "FOOBAR" + ) ) ) - )) + ) ProcessComponentWidget(processViewModel, QInstance(), component) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ProcessComponentWidgetPreviewNonAdHoc() -{ +fun ProcessComponentWidgetPreviewNonAdHoc() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() - val component = QFrontendComponent(type = QComponentType.WIDGET, values = mapOf( - "widgetName" to "testWidget" - )) + val component = QFrontendComponent( + type = QComponentType.WIDGET, values = mapOf( + "widgetName" to "testWidget" + ) + ) ProcessComponentWidget(processViewModel, QInstance(), component) } diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/UnsupportedProcessComponentPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/UnsupportedProcessComponentPreview.kt index 57f0359..b940f4a 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/UnsupportedProcessComponentPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/processes/components/UnsupportedProcessComponentPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -29,13 +30,12 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent import com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.components.UnsupportedProcessComponent import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun UnsupportedProcessComponentPreview() -{ +fun UnsupportedProcessComponentPreview() { val processViewModel = ProcessViewModel() processViewModel.qqqRepository = SampleAppMockQQQRepository() val component = QFrontendComponent(type = QComponentType.HTML) diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/utils/PreviewUtils.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/utils/PreviewUtils.kt index b5dc0e2..0235eef 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/utils/PreviewUtils.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/utils/PreviewUtils.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -26,16 +27,14 @@ 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.viewmodel.QViewModel -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -object PreviewUtils -{ - /*************************************************************************** + *******************************************************************************/ +object PreviewUtils { + /******************************************************************************* ** - ***************************************************************************/ - fun createQViewModel(): QViewModel - { + *******************************************************************************/ + fun createQViewModel(): QViewModel { QAppContainer.availableEnvironments = listOf( Environment("Production", "https://live.coldtrack.com/"), Environment("Staging", "https://live.coldtrack-staging.com/"), @@ -47,4 +46,4 @@ object PreviewUtils val qViewModel = QViewModel(SampleAppMockQQQRepository(), dataStore) return (qViewModel) } -} \ No newline at end of file +} diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/ButtonWidgetBlockPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/ButtonWidgetBlockPreview.kt index 5271ddd..cd813e5 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/ButtonWidgetBlockPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/ButtonWidgetBlockPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -34,39 +35,39 @@ import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.ButtonWidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters +/******************************************************************************* + ** + *******************************************************************************/ @Composable fun ActionButton( styles: MutableMapOfStringAny = emptyMap().toMutableMap(), values: MutableMapOfStringAny = emptyMap().toMutableMap() ) = ButtonWidgetBlock(WidgetBlockParameters(WidgetBlock("", "BUTTON", styles = styles, values = values))) -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockDefault() -{ +fun ActionButtonWidgetBlockDefault() { ActionButton(values = mutableMapOf("label" to "Default")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockSuccess() -{ +fun ActionButtonWidgetBlockSuccess() { ActionButton(styles = mutableMapOf("color" to "SUCCESS"), values = mutableMapOf("label" to "Default")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockOutlined() -{ +fun ActionButtonWidgetBlockOutlined() { Surface(modifier = Modifier.background(Color.Gray).padding(20.dp)) { ActionButton(styles = mutableMapOf("format" to "outlined"), values = mutableMapOf("label" to "Outlined")) @@ -74,78 +75,70 @@ fun ActionButtonWidgetBlockOutlined() } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockOutlinedError() -{ +fun ActionButtonWidgetBlockOutlinedError() { ActionButton(styles = mutableMapOf("color" to "ERROR", "format" to "outlined"), values = mutableMapOf("label" to "Outlined")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockFilled() -{ +fun ActionButtonWidgetBlockFilled() { ActionButton(styles = mutableMapOf("format" to "filled"), values = mutableMapOf("label" to "Filled")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockFilledError() -{ +fun ActionButtonWidgetBlockFilledError() { ActionButton(styles = mutableMapOf("color" to "SUCCESS", "format" to "filled"), values = mutableMapOf("label" to "Filled")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockText() -{ +fun ActionButtonWidgetBlockText() { ActionButton(styles = mutableMapOf("format" to "text"), values = mutableMapOf("label" to "Text")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockTextError() -{ +fun ActionButtonWidgetBlockTextError() { ActionButton(styles = mutableMapOf("color" to "ERROR", "format" to "text"), values = mutableMapOf("label" to "Text")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockStartIcon() -{ +fun ActionButtonWidgetBlockStartIcon() { ActionButton(styles = mutableMapOf("color" to "INFO", "format" to "outlined"), values = mutableMapOf("label" to "Text", "startIcon" to mapOf("name" to "check"))) } - -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun ActionButtonWidgetBlockEndIcon() -{ +fun ActionButtonWidgetBlockEndIcon() { ActionButton(styles = mutableMapOf("color" to "WARNING", "format" to "filled"), values = mutableMapOf("label" to "Text", "endIcon" to mapOf("name" to "warning"))) } diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/CompositeWidgetBlockPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/CompositeWidgetBlockPreview.kt index 9c59c02..fb5bc4a 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/CompositeWidgetBlockPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/CompositeWidgetBlockPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -27,13 +28,17 @@ import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.CompositeWidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters -fun makeTextBlock(text: String): WidgetBlock -{ +/******************************************************************************* + ** + *******************************************************************************/ +fun makeTextBlock(text: String): WidgetBlock { return WidgetBlock("", "TEXT", values = mutableMapOf("text" to text)) } -fun makeCompositeBlock(layout: String, subBlocks: List): WidgetBlock -{ +/******************************************************************************* + ** + *******************************************************************************/ +fun makeCompositeBlock(layout: String, subBlocks: List): WidgetBlock { return WidgetBlock("", "COMPOSITE", layout = layout, subBlocks = subBlocks) } @@ -42,40 +47,39 @@ val twoSubBlocks = listOf( makeTextBlock("two") ) +/******************************************************************************* + ** + *******************************************************************************/ @Composable -fun Test(layout: String, subBlocks: List) -{ +fun Test(layout: String, subBlocks: List) { val params = WidgetBlockParameters(WidgetBlock("", "COMPOSITE", layout = layout, subBlocks = subBlocks), null) CompositeWidgetBlock(params) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 200) @Composable -fun CompositeWidgetBlockFlexRowCenter() -{ +fun CompositeWidgetBlockFlexRowCenter() { Test("FLEX_ROW_CENTER", twoSubBlocks) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 200) @Composable -fun CompositeWidgetBlockFlexRowSpaceBetween() -{ +fun CompositeWidgetBlockFlexRowSpaceBetween() { Test("FLEX_ROW_SPACE_BETWEEN", twoSubBlocks) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 200) @Composable -fun CompositeWidgetBlockFlexRowWrapped() -{ +fun CompositeWidgetBlockFlexRowWrapped() { Test( "FLEX_ROW_WRAPPED", listOf( makeTextBlock("something"), makeTextBlock("something else"), makeTextBlock("a third thing"), makeTextBlock("and ...") @@ -83,23 +87,21 @@ fun CompositeWidgetBlockFlexRowWrapped() ) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 200) @Composable -fun CompositeWidgetBlockFlexColumn() -{ +fun CompositeWidgetBlockFlexColumn() { Test("FLEX_COLUMN", twoSubBlocks) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview(widthDp = 200) @Composable -fun CompositeWidgetBlockThingForScanner() -{ +fun CompositeWidgetBlockThingForScanner() { Test( "FLEX_ROW_WRAPPED", listOf( makeTextBlock("Icon"), diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/InputFieldWidgetBlockPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/InputFieldWidgetBlockPreview.kt index d10cae0..eecc275 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/InputFieldWidgetBlockPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/InputFieldWidgetBlockPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -27,13 +28,12 @@ import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.InputFieldWidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun InputFieldWidgetBlockPreview() -{ +fun InputFieldWidgetBlockPreview() { val values = mutableMapOf( "submitOnEnter" to false, "autoFocus" to true, diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/TextWidgetBlockPreview.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/TextWidgetBlockPreview.kt index a203fed..cd9f2d7 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/TextWidgetBlockPreview.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/previews/widgets/blocks/TextWidgetBlockPreview.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -28,238 +29,218 @@ import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.TextWidgetBlock import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters +/******************************************************************************* + ** + *******************************************************************************/ @Composable fun TextBlock( styles: MutableMapOfStringAny = emptyMap().toMutableMap(), values: MutableMapOfStringAny = emptyMap().toMutableMap() ) = TextWidgetBlock(WidgetBlockParameters(WidgetBlock("", "TEXT", styles = styles, values = values))) -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockDefault() -{ +fun TextWidgetBlockDefault() { TextBlock(values = mutableMapOf("text" to "Default color")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockAlert() -{ +fun TextWidgetBlockAlert() { TextBlock(mutableMapOf("format" to "alert"), mutableMapOf("text" to "Default color")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockUnknownColor() -{ +fun TextWidgetBlockUnknownColor() { TextBlock(mutableMapOf("color" to "un known"), mutableMapOf("text" to "Unknown color")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockSuccess() -{ +fun TextWidgetBlockSuccess() { TextBlock(mutableMapOf("color" to "SUCCESS"), mutableMapOf("text" to "Great Success")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockSuccessAlert() -{ +fun TextWidgetBlockSuccessAlert() { TextBlock(mutableMapOf("color" to "SUCCESS", "format" to "alert"), mutableMapOf("text" to "Success alert")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockDefaultColorBanner() -{ +fun TextWidgetBlockDefaultColorBanner() { TextBlock(mutableMapOf("format" to "banner"), mutableMapOf("text" to "Default banner!")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockMutedBanner() -{ +fun TextWidgetBlockMutedBanner() { TextBlock(mutableMapOf("color" to "MUTED", "format" to "banner"), mutableMapOf("text" to "Muted banner!")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockErrorBanner() -{ +fun TextWidgetBlockErrorBanner() { TextBlock(mutableMapOf("color" to "ERROR", "format" to "banner"), mutableMapOf("text" to "Error banner!")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockSuccessBanner() -{ +fun TextWidgetBlockSuccessBanner() { TextBlock(mutableMapOf("color" to "SUCCESS", "format" to "banner"), mutableMapOf("text" to "Success banner!")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockWarning() -{ +fun TextWidgetBlockWarning() { TextBlock(mutableMapOf("color" to "WARNING"), mutableMapOf("text" to "Warning Will")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockError() -{ +fun TextWidgetBlockError() { TextBlock(mutableMapOf("color" to "ERROR"), mutableMapOf("text" to "Error Jordan")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockInfo() -{ +fun TextWidgetBlockInfo() { TextBlock(mutableMapOf("color" to "INFO"), mutableMapOf("text" to "Info matic")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockMuted() -{ +fun TextWidgetBlockMuted() { TextBlock(mutableMapOf("color" to "MUTED"), mutableMapOf("text" to "Shhh!")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockInterpolated() -{ +fun TextWidgetBlockInterpolated() { TextBlock(values = mutableMapOf("text" to "\${someVar}", "interpolatedText" to "Some Val")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockSemiBold() -{ +fun TextWidgetBlockSemiBold() { TextBlock(mutableMapOf("weight" to "semibold"), mutableMapOf("text" to "boldly")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockWeight100() -{ +fun TextWidgetBlockWeight100() { TextBlock(mutableMapOf("weight" to "100"), mutableMapOf("text" to "thin 100")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockWeight900() -{ +fun TextWidgetBlockWeight900() { TextBlock(mutableMapOf("weight" to "900"), mutableMapOf("text" to "thick 900")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockSizeLargest() -{ +fun TextWidgetBlockSizeLargest() { TextBlock(mutableMapOf("size" to "largest"), mutableMapOf("text" to "big")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockSize6() -{ +fun TextWidgetBlockSize6() { TextBlock(mutableMapOf("size" to "6"), mutableMapOf("text" to "so 6")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBlockSize26() -{ +fun TextWidgetBlockSize26() { TextBlock(mutableMapOf("size" to "26"), mutableMapOf("text" to "so 26")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetRGBColor() -{ +fun TextWidgetRGBColor() { TextBlock(mutableMapOf("color" to "00FF80"), mutableMapOf("text" to "some teal")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetRGBAColor() -{ +fun TextWidgetRGBAColor() { TextBlock(mutableMapOf("color" to "00FF8080"), mutableMapOf("text" to "some teal")) } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @Preview @Composable -fun TextWidgetBannerAndIcon() -{ +fun TextWidgetBannerAndIcon() { TextBlock(mutableMapOf("format" to "banner"), mutableMapOf("startIcon" to "verified_user", "text" to "Hello there")) -} \ No newline at end of file +} diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/utils/TestUtils.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/utils/TestUtils.kt index 72582e4..9f4f649 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/utils/TestUtils.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/utils/TestUtils.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -39,25 +40,20 @@ import com.kingsrook.qqq.sampleandroidmobileapp.SampleAppMockQQQRepository import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -object TestUtils -{ + *******************************************************************************/ +object TestUtils { lateinit var qViewModel: QViewModel lateinit var toast: Toast - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - fun startStandardTest(composeTestRule: ComposeContentTestRule, qViewModelParam: QViewModel? = null) - { - if(qViewModelParam == null) - { + *******************************************************************************/ + fun startStandardTest(composeTestRule: ComposeContentTestRule, qViewModelParam: QViewModel? = null) { + if (qViewModelParam == null) { this.qViewModel = createQViewModel() - } - else - { + } else { this.qViewModel = qViewModelParam } @@ -72,96 +68,85 @@ object TestUtils } } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - fun login(composeTestRule: ComposeContentTestRule) - { + *******************************************************************************/ + fun login(composeTestRule: ComposeContentTestRule) { composeTestRule.onNodeWithText("Log In").performClick() - composeTestRule.waitUntil("Should become authed", timeoutMillis = 5000, condition = - { - qViewModel.isAuthenticated - }) + composeTestRule.waitUntil( + "Should become authed", timeoutMillis = 5000, condition = + { + qViewModel.isAuthenticated + }) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - fun createQViewModel(): QViewModel - { + *******************************************************************************/ + fun createQViewModel(): QViewModel { val dataStore = MockPreferencesDataStore() val qViewModel = QViewModel(SampleAppMockQQQRepository(), dataStore) return (qViewModel) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - fun sleep(composeTestRule: ComposeContentTestRule, seconds: Int) - { - composeTestRule.waitUntil("Intentional Sleep", timeoutMillis = (seconds + 1) * 1000L, condition = - { - try - { - Thread.sleep(seconds * 1000L) - return@waitUntil true - } - catch(e: Exception) - { - // noop, just return - return@waitUntil true - } - }) + *******************************************************************************/ + fun sleep(composeTestRule: ComposeContentTestRule, seconds: Int) { + composeTestRule.waitUntil( + "Intentional Sleep", timeoutMillis = (seconds + 1) * 1000L, condition = + { + try { + Thread.sleep(seconds * 1000L) + return@waitUntil true + } catch (e: Exception) { + // noop, just return + return@waitUntil true + } + }) } - /*************************************************************************** + /******************************************************************************* ** Sleep, but, only when running locally, e.g., to see the screen and debug. ** will this cause tests to behave differently in CI? We shall see! - ***************************************************************************/ - fun localDebugSleep(composeTestRule: ComposeContentTestRule, seconds: Int = 300) - { - if(seconds == 0) - { + *******************************************************************************/ + fun localDebugSleep(composeTestRule: ComposeContentTestRule, seconds: Int = 300) { + if (seconds == 0) { return } - if(System.getenv("CIRCLECI") != null) - { + if (System.getenv("CIRCLECI") != null) { println("In CIRCLECI - so - not doing a localDebugSleep") return } - showToast("Going into a localDebugSleep sleep \uD83D\uDECC now for ${seconds} second${if(seconds == 1) "" else "s"} ⏰") + showToast("Going into a localDebugSleep sleep \uD83D\uDECC now for ${seconds} second${if (seconds == 1) "" else "s"} ⏰") sleep(composeTestRule, seconds) } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - fun logTree(composeTestRule: ComposeContentTestRule, useUnmergedTree: Boolean = false) - { + *******************************************************************************/ + fun logTree(composeTestRule: ComposeContentTestRule, useUnmergedTree: Boolean = false) { composeTestRule.onRoot(useUnmergedTree = useUnmergedTree) .printToLog("currentLabelExists") } - /*************************************************************************** + /******************************************************************************* ** - ***************************************************************************/ - fun showToast(text: String) - { + *******************************************************************************/ + fun showToast(text: String) { toast.setText(text) toast.show() } } -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ + *******************************************************************************/ @OptIn(ExperimentalTestApi::class) // for waitUntilAtLeastOneExists -fun ComposeContentTestRule.clickAppScreenOption(type: QAppNodeType, childName: String) -{ - val testTag = "appHome.rowFor" + when(type) - { +fun ComposeContentTestRule.clickAppScreenOption(type: QAppNodeType, childName: String) { + val testTag = "appHome.rowFor" + when (type) { QAppNodeType.APP -> "App:" QAppNodeType.TABLE -> "Table:" QAppNodeType.PROCESS -> "Process:" diff --git a/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleAppMainActivity.kt b/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleAppMainActivity.kt index b9d7dde..541a35b 100644 --- a/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleAppMainActivity.kt +++ b/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/SampleAppMainActivity.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2004-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -42,16 +43,14 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import com.kingsrook.qqq.sampleandroidmobileapp.ui.theme.AppTheme import java.io.File -/*************************************************************************** +/******************************************************************************* ** - ***************************************************************************/ -class SampleAppMainActivity : ComponentActivity() -{ - /*************************************************************************** + *******************************************************************************/ +class SampleAppMainActivity : ComponentActivity() { + /******************************************************************************* ** - ***************************************************************************/ - override fun onCreate(savedInstanceState: Bundle?) - { + *******************************************************************************/ + override fun onCreate(savedInstanceState: Bundle?) { ///////////////////////////////////////////////////////////////////////////////////////////// // configure your application: // // availableEnvironments: list of Environments - labels & Urls of your application servers // @@ -67,7 +66,7 @@ class SampleAppMainActivity : ComponentActivity() Environment("Production", "https://live.coldtrack.com/"), Environment("Staging", "https://live.coldtrack-staging.com/"), Environment("Development", "https://live.coldtrack-dev.com/"), - ) + if(BuildConfig.DEBUG) listOf( + ) + if (BuildConfig.DEBUG) listOf( Environment("Local Dev", "http://192.168.4.122:8000/"), Environment("Bad URL", "http://no.such.domain.dot.dot/"), ) else emptyList() @@ -94,7 +93,7 @@ class SampleAppMainActivity : ComponentActivity() AppTheme() { - QMobileAppMain(qViewModel, title = title, if(splashResourceId == null) null else painterResource(splashResourceId)) + QMobileAppMain(qViewModel, title = title, if (splashResourceId == null) null else painterResource(splashResourceId)) } } } diff --git a/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Color.kt b/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Color.kt index 8649334..5e02b68 100644 --- a/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Color.kt +++ b/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Color.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -28,4 +29,4 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) diff --git a/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Theme.kt b/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Theme.kt index b45bf56..5f6461c 100644 --- a/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Theme.kt +++ b/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Theme.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -53,20 +54,20 @@ private val LightColorScheme = lightColorScheme( */ ) +/******************************************************************************* + ** + *******************************************************************************/ @Composable fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit -) -{ - val colorScheme = when - { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> - { +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current - if(darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> DarkColorScheme @@ -78,4 +79,4 @@ fun AppTheme( typography = Typography, content = content ) -} \ No newline at end of file +} diff --git a/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Type.kt b/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Type.kt index 76c944e..9a4ee83 100644 --- a/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Type.kt +++ b/qqqsampleandroidmobileapp/src/main/java/com/kingsrook/qqq/sampleandroidmobileapp/ui/theme/Type.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -51,4 +52,4 @@ val Typography = Typography( letterSpacing = 0.5.sp ) */ -) \ No newline at end of file +) diff --git a/qqqsampleandroidmobileapp/src/test/java/com/kingsrook/qqq/sampleandroidmobileapp/ExampleUnitTest.kt b/qqqsampleandroidmobileapp/src/test/java/com/kingsrook/qqq/sampleandroidmobileapp/ExampleUnitTest.kt index 202c14d..6fd8574 100644 --- a/qqqsampleandroidmobileapp/src/test/java/com/kingsrook/qqq/sampleandroidmobileapp/ExampleUnitTest.kt +++ b/qqqsampleandroidmobileapp/src/test/java/com/kingsrook/qqq/sampleandroidmobileapp/ExampleUnitTest.kt @@ -1,4 +1,5 @@ /* + * SPDX-License-Identifier: Apache-2.0 * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2024-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States @@ -22,18 +23,18 @@ package com.kingsrook.qqq.sampleandroidmobileapp import org.junit.Test -import org.junit.Assert.* +import org.junit.Assert.assertEquals -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest -{ +/******************************************************************************* + ** Example local unit test, which will execute on the development machine (host). + ** See [testing documentation](http://d.android.com/tools/testing). + *******************************************************************************/ +class ExampleUnitTest { + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun addition_isCorrect() - { + fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/scanner-host/build.gradle.kts b/scanner-host/build.gradle.kts index 0d08271..e0d6eee 100644 --- a/scanner-host/build.gradle.kts +++ b/scanner-host/build.gradle.kts @@ -1,3 +1,4 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ import java.security.MessageDigest plugins { diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/AppScreenCaptureTest.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/AppScreenCaptureTest.kt index f3475d9..7a88807 100644 --- a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/AppScreenCaptureTest.kt +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/AppScreenCaptureTest.kt @@ -17,18 +17,25 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlinx.coroutines.launch -import org.junit.Assert.* +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +/******************************************************************************* + ** + *******************************************************************************/ @RunWith(AndroidJUnit4::class) -class AppScreenCaptureTest -{ - @get:Rule val ui = createAndroidComposeRule() +class AppScreenCaptureTest { + @get:Rule + val ui = createAndroidComposeRule() - @Test fun pixelCopyCapturesActualAppPixelsAsABoundedJpeg() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun pixelCopyCapturesActualAppPixelsAsABoundedJpeg() { ui.runOnUiThread { ui.activity.setContent { Row(Modifier.fillMaxSize()) { @@ -46,8 +53,11 @@ class AppScreenCaptureTest view.getLocationInWindow(location) val bounds = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height) ui.activity.lifecycleScope.launch { - try { result = captureAppScreen(ui.activity.window, bounds, ScreenLease("test", 30000, 1000, 640, 1280, 262144, 0)) } - finally { done.countDown() } + try { + result = captureAppScreen(ui.activity.window, bounds, ScreenLease("test", 30000, 1000, 640, 1280, 262144, 0)) + } finally { + done.countDown() + } } } assertTrue("PixelCopy must finish", done.await(20, TimeUnit.SECONDS)) diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/DiagnosticScreenTest.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/DiagnosticScreenTest.kt index b7a668d..339f0c3 100644 --- a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/DiagnosticScreenTest.kt +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/DiagnosticScreenTest.kt @@ -14,13 +14,19 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +/******************************************************************************* + ** + *******************************************************************************/ @RunWith(AndroidJUnit4::class) -class DiagnosticScreenTest -{ - @get:Rule val ui = createAndroidComposeRule() +class DiagnosticScreenTest { + @get:Rule + val ui = createAndroidComposeRule() - @Test fun filtersPausesClearsAndReturnsWithoutNetworkOrCredentials() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun filtersPausesClearsAndReturnsWithoutNetworkOrCredentials() { val log = DiagnosticLog() log.record(DiagnosticCode.CONNECTION_OK, 200, 15) log.record(DiagnosticCode.DEVICE_RESPONSE, 403, 25) diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/OpenSourceLicensesTest.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/OpenSourceLicensesTest.kt index babfdd0..8178d2b 100644 --- a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/OpenSourceLicensesTest.kt +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/OpenSourceLicensesTest.kt @@ -21,14 +21,19 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -/** Uses the actual generated APK resources; no server or license download is required. */ +/******************************************************************************* + ** Uses the actual generated APK resources; no server or license download is required. + *******************************************************************************/ @RunWith(AndroidJUnit4::class) -class OpenSourceLicensesTest -{ - @get:Rule val ui = createAndroidComposeRule() +class OpenSourceLicensesTest { + @get:Rule + val ui = createAndroidComposeRule() - @Test fun bundledNoticesOpenAndCloseFromSettings() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun bundledNoticesOpenAndCloseFromSettings() { ui.runOnUiThread { ui.activity.setContent { MaterialTheme { OpenSourceLicensesButton() } } } diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/OrientationPolicyTest.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/OrientationPolicyTest.kt index cfef515..8e4ab3e 100644 --- a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/OrientationPolicyTest.kt +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/OrientationPolicyTest.kt @@ -6,10 +6,15 @@ import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Test -class OrientationPolicyTest -{ - @Test fun deviceSelectionSurvivesRecreationAndColdActivityLaunch() - { +/******************************************************************************* + ** + *******************************************************************************/ +class OrientationPolicyTest { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun deviceSelectionSurvivesRecreationAndColdActivityLaunch() { val context = InstrumentationRegistry.getInstrumentation().targetContext check(context.packageName == "io.qrun.mobile.scanner.e2e") OrientationPolicy.entries.forEach { policy -> @@ -17,24 +22,21 @@ class OrientationPolicyTest scenario.onActivity { policy.select(it) } InstrumentationRegistry.getInstrumentation().waitForIdleSync() scenario.recreate() - if(policy != OrientationPolicy.SYSTEM) - { - val expected = if(policy == OrientationPolicy.PORTRAIT || policy == OrientationPolicy.REVERSE_PORTRAIT) + if (policy != OrientationPolicy.SYSTEM) { + val expected = if (policy == OrientationPolicy.PORTRAIT || policy == OrientationPolicy.REVERSE_PORTRAIT) android.content.res.Configuration.ORIENTATION_PORTRAIT else android.content.res.Configuration.ORIENTATION_LANDSCAPE val deadline = android.os.SystemClock.uptimeMillis() + 15000 var applied = false - while(!applied && android.os.SystemClock.uptimeMillis() < deadline) - { + while (!applied && android.os.SystemClock.uptimeMillis() < deadline) { scenario.onActivity { applied = it.resources.configuration.orientation == expected } - if(!applied) android.os.SystemClock.sleep(100) + if (!applied) android.os.SystemClock.sleep(100) } } scenario.onActivity { assertEquals(policy, OrientationPolicy.read(it)) assertEquals(policy.requested, it.requestedOrientation) - if(policy != OrientationPolicy.SYSTEM) - { - val expected = if(policy == OrientationPolicy.PORTRAIT || policy == OrientationPolicy.REVERSE_PORTRAIT) + if (policy != OrientationPolicy.SYSTEM) { + val expected = if (policy == OrientationPolicy.PORTRAIT || policy == OrientationPolicy.REVERSE_PORTRAIT) android.content.res.Configuration.ORIENTATION_PORTRAIT else android.content.res.Configuration.ORIENTATION_LANDSCAPE assertEquals("The handheld applies the selected screen axis", expected, it.resources.configuration.orientation) } diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt index 2b56bb0..9cff631 100644 --- a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/ScannerEndToEndTest.kt @@ -49,20 +49,22 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -/** Real host UI and real QQQ server. No repository mocks or production app data. */ +/******************************************************************************* + ** Real host UI and real QQQ server. No repository mocks or production app data. + *******************************************************************************/ @RunWith(AndroidJUnit4::class) -class ScannerEndToEndTest -{ +class ScannerEndToEndTest { @get:Rule val ui = createAndroidComposeRule() private val client = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build() private lateinit var origin: String private lateinit var adminCookie: String - /** Real host/native activity-result boundaries and real upload APIs, using synthetic images only. */ + /******************************************************************************* + ** Real host/native activity-result boundaries and real upload APIs, using synthetic images only. + *******************************************************************************/ @Test - fun cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout() - { + fun cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout() { val instrumentation = InstrumentationRegistry.getInstrumentation() val context = instrumentation.targetContext check(context.packageName == "io.qrun.mobile.scanner.e2e") @@ -78,20 +80,19 @@ class ScannerEndToEndTest val galleryUri = FileProvider.getUriForFile(context, context.packageName + ".photos", galleryFile) val cameraCalls = AtomicInteger() val galleryCalls = AtomicInteger() - val monitor = object : Instrumentation.ActivityMonitor() - { - override fun onStartActivity(intent: Intent): Instrumentation.ActivityResult? - { - if(intent.action == MediaStore.ACTION_IMAGE_CAPTURE) - { + val monitor = object : Instrumentation.ActivityMonitor() { + /******************************************************************************* + ** + *******************************************************************************/ + override fun onStartActivity(intent: Intent): Instrumentation.ActivityResult? { + if (intent.action == MediaStore.ACTION_IMAGE_CAPTURE) { cameraCalls.incrementAndGet() // API26-compatible parcelable access; this URI is created by the tested host's FileProvider. @Suppress("DEPRECATION") val output = intent.getParcelableExtra(MediaStore.EXTRA_OUTPUT)!! context.contentResolver.openOutputStream(output)!!.use { it.write(bytes) } return Instrumentation.ActivityResult(Activity.RESULT_OK, null) } - if(intent.action in setOf(MediaStore.ACTION_PICK_IMAGES, Intent.ACTION_OPEN_DOCUMENT, Intent.ACTION_GET_CONTENT)) - { + if (intent.action in setOf(MediaStore.ACTION_PICK_IMAGES, Intent.ACTION_OPEN_DOCUMENT, Intent.ACTION_GET_CONTENT)) { galleryCalls.incrementAndGet() return Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(galleryUri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)) } @@ -99,8 +100,7 @@ class ScannerEndToEndTest } } instrumentation.addMonitor(monitor) - try - { + try { waitFor("QQQ server URL") ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/") ui.onNodeWithText("Connect", substring = false).performClick() @@ -162,23 +162,23 @@ class ScannerEndToEndTest } val launch = processCall("mobileSessionEnd", "/init", JSONObject()) val ended = processCall("mobileSessionEnd", "/${launch.getString("processUUID")}/step/input", JSONObject().put("sessionId", sessionId)) - if(ended.optString("nextStep") == "result") processCall("mobileSessionEnd", "/${launch.getString("processUUID")}/step/result", JSONObject()) + if (ended.optString("nextStep") == "result") processCall("mobileSessionEnd", "/${launch.getString("processUUID")}/step/result", JSONObject()) waitFor("Log back in") waitForServer { !pending.exists() } assertTrue("The selected gallery original is never deleted", galleryFile.exists()) assertEquals("No late callback created a second camera action", 1, cameraCalls.get()) stage("native-photo-forced-logout-cleaned") - } - finally - { + } finally { instrumentation.removeMonitor(monitor) galleryFile.delete() } } + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin() - { + fun sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin() { val context = InstrumentationRegistry.getInstrumentation().targetContext check(context.packageName in setOf("io.qrun.mobile.scanner.e2e", "io.qrun.mobile.scanner.qa")) val configFile = File(context.filesDir, "e2e.json") @@ -188,12 +188,14 @@ class ScannerEndToEndTest waitFor("QQQ server URL") ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/") ui.onNodeWithText("Connect", substring = false).performClick() - fun signIn() - { + /******************************************************************************* + ** + *******************************************************************************/ + fun signIn() { waitFor("Username") ui.onNodeWithText("Username").performTextReplacement("scanner") ui.onNodeWithText("Password").performTextReplacement(config.getString("scannerPassword")) - ui.onNodeWithText("Password").performImeAction() + ui.onNodeWithText("Password").performImeAction() ui.onNode(hasText("Sign in") and hasClickAction()).performScrollTo().performClick() } signIn() @@ -202,9 +204,9 @@ class ScannerEndToEndTest adminCookie = loginAdmin(config.getString("adminPassword")) runAdmin("mobileDeviceApprove", device.deviceId) ui.onNodeWithText("Check approval").performScrollTo().assertIsDisplayed().performClick() - try { waitForTag("appHome.rowForApp:mobileScanApp") } - catch(error: androidx.compose.ui.test.ComposeTimeoutException) - { + try { + waitForTag("appHome.rowForApp:mobileScanApp") + } catch (error: androidx.compose.ui.test.ComposeTimeoutException) { val safeTree = ui.onRoot().printToString().replace(config.getString("scannerPassword"), "") .replace(config.getString("adminPassword"), "") throw AssertionError("Approved app catalog did not load: $safeTree", error) @@ -242,16 +244,16 @@ class ScannerEndToEndTest val deviceRows = jsonRequest("/qqq/v1/table/mobileDevice/query", JSONObject()).getJSONArray("records") val deviceRow = (0 until deviceRows.length()).map { deviceRows.getJSONObject(it).getJSONObject("values") }.single { it.getString("id") == device.deviceId } assertEquals("PERMISSION_DENIED", deviceRow.getString("locationStatus")) + /******************************************************************************* + ** + *******************************************************************************/ fun screenWidget() = jsonRequest("/qqq/v1/widget/mobileSessionScreen", JSONObject().put("id", sessionId)) - try - { + try { waitForServer { row = sessionRows().single { it.getString("id") == sessionId } row.optBoolean("screenAvailable") } - } - catch(error: AssertionError) - { + } catch (error: AssertionError) { var windowState = "unavailable" ui.activityRule.scenario.onActivity { activity -> val power = activity.getSystemService(android.os.PowerManager::class.java) @@ -262,17 +264,14 @@ class ScannerEndToEndTest } // Match the ordinary admin widget's polling lease, including transient screen changes. var widgetStatus = "unobserved" - try - { + try { waitForServer { val widget = screenWidget() val blocks = widget.optJSONArray("blocks") ?: widget.optJSONArray("subBlocks") widgetStatus = blocks?.optJSONObject(0)?.optJSONObject("values")?.optString("text").orEmpty() ui.onAllNodesWithText("Screen being viewed by an administrator").fetchSemanticsNodes().isNotEmpty() } - } - catch(error: AssertionError) - { + } catch (error: AssertionError) { val latest = sessionRows().single { it.getString("id") == sessionId } throw AssertionError("Viewing demand did not converge: available=${latest.optBoolean("screenAvailable")}, reason=${latest.optString("captureBlockedReason")}, lastSeenAt=${latest.optString("lastSeenAt")}, deviceTime=${java.time.Instant.now()}, widgetStatus=$widgetStatus", error) } @@ -298,7 +297,7 @@ class ScannerEndToEndTest waitForServer { screenWidget().toString().contains("OPERATOR_PAUSED") && findImage(screenWidget()) == null } val launch = processCall("mobileSessionEnd", "/init", JSONObject()) val applied = processCall("mobileSessionEnd", "/${launch.getString("processUUID")}/step/input", JSONObject().put("sessionId", sessionId)) - if(applied.optString("nextStep") == "result") processCall("mobileSessionEnd", "/${launch.getString("processUUID")}/step/result", JSONObject()) + if (applied.optString("nextStep") == "result") processCall("mobileSessionEnd", "/${launch.getString("processUUID")}/step/result", JSONObject()) waitFor("Log back in") assertEquals("ENDED", sessionRows().single { it.getString("id") == sessionId }.getString("status")) // The label is rendered during asynchronous logout while its button is disabled. @@ -317,32 +316,40 @@ class ScannerEndToEndTest stage("session-fresh-login") } - private fun sessionRows(): List - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun sessionRows(): List { return SessionAcceptanceClient(origin, adminCookie, client).sessions() } - private fun waitForServer(condition: () -> Boolean) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun waitForServer(condition: () -> Boolean) { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(40) var lastClockAdvance = System.nanoTime() - while(true) - { + while (true) { // LaunchedEffect timers use this clock; keep it aligned with real network/polling time. val now = System.nanoTime() ui.mainClock.advanceTimeBy(TimeUnit.NANOSECONDS.toMillis(now - lastClockAdvance).coerceAtLeast(16)) lastClockAdvance = now - if(condition()) return + if (condition()) return assertTrue("Session server state must converge", System.nanoTime() < deadline) Thread.sleep(250) } } + /******************************************************************************* + ** + *******************************************************************************/ private fun findImage(value: Any?): String? = SessionAcceptanceClient.image(value) + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun adminFormsApproveRevokeAndCancel() - { + fun adminFormsApproveRevokeAndCancel() { stage("admin-start") val context = InstrumentationRegistry.getInstrumentation().targetContext check(context.packageName == "io.qrun.mobile.scanner.e2e") @@ -353,9 +360,9 @@ class ScannerEndToEndTest waitFor("QQQ server URL") ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/") ui.onNodeWithText("Connect", substring = false).performClick() - try { waitFor("Username") } - catch(error: androidx.compose.ui.test.ComposeTimeoutException) - { + try { + waitFor("Username") + } catch (error: androidx.compose.ui.test.ComposeTimeoutException) { // This is before any credentials are entered; retain the actual startup error for diagnosis. throw AssertionError("Admin login screen did not load: ${ui.onRoot().printToString()}", error) } @@ -368,9 +375,11 @@ class ScannerEndToEndTest stage("admin-enrolled") adminCookie = loginAdmin(config.getString("adminPassword")) runAdmin("mobileDeviceApprove", DeviceCredentials(context, "$origin/").deviceId) - val targetDevice = jsonRequest("/qqq/v1/qbit/mobile-scanners/devices/enroll", JSONObject() - .put("installationId", UUID.randomUUID().toString()).put("displayName", "Native form fixture") - .put("manufacturer", "Test").put("model", "Emulator").put("osVersion", "test").put("appVersion", "test"), expectedStatus = 201) + val targetDevice = jsonRequest( + "/qqq/v1/qbit/mobile-scanners/devices/enroll", JSONObject() + .put("installationId", UUID.randomUUID().toString()).put("displayName", "Native form fixture") + .put("manufacturer", "Test").put("model", "Emulator").put("osVersion", "test").put("appVersion", "test"), expectedStatus = 201 + ) val target = targetDevice.getString("deviceId") ui.onNodeWithText("Check approval").performScrollTo().assertIsDisplayed().performClick() waitForTag("appHome.rowForApp:mobileScannerAdmin") @@ -378,8 +387,8 @@ class ScannerEndToEndTest stage("admin-app-open") for ((process, status, result) in listOf( Triple("mobileDeviceApprove", "APPROVED", "1 device approved."), - Triple("mobileDeviceRevoke", "REVOKED", "1 device revoked."))) - { + Triple("mobileDeviceRevoke", "REVOKED", "1 device revoked.") + )) { stage("admin-open-$process") waitForTag("appHome.rowForProcess:$process") ui.onNodeWithTag("appHome.rowForProcess:$process").performClick() @@ -399,12 +408,16 @@ class ScannerEndToEndTest // A separate core login represents another handheld; end it through the native metadata form. runAdmin("mobileDeviceApprove", target) val fixtureCookie = loginAdmin(config.getString("scannerPassword"), "scanner") - val heartbeat = executeJson(Request.Builder().url("$origin/qqq/v1/qbit/mobile-scanners/sessions/heartbeat") - .header("Cookie", fixtureCookie).header("X-QQQ-Device-Id", target) - .header("X-QQQ-Device-Token", targetDevice.getString("deviceToken")) - .header("X-QQQ-Client", "android/qqq-mobile") - .post(JSONObject().put("foreground", true).put("screenAvailable", false).put("captureBlockedReason", "SCREEN_LOADING") - .toString().toRequestBody("application/json".toMediaType())).build()) + val heartbeat = executeJson( + Request.Builder().url("$origin/qqq/v1/qbit/mobile-scanners/sessions/heartbeat") + .header("Cookie", fixtureCookie).header("X-QQQ-Device-Id", target) + .header("X-QQQ-Device-Token", targetDevice.getString("deviceToken")) + .header("X-QQQ-Client", "android/qqq-mobile") + .post( + JSONObject().put("foreground", true).put("screenAvailable", false).put("captureBlockedReason", "SCREEN_LOADING") + .toString().toRequestBody("application/json".toMediaType()) + ).build() + ) val targetSession = heartbeat.getString("sessionId") waitForTag("appHome.rowForProcess:mobileSessionEnd") ui.onNodeWithTag("appHome.rowForProcess:mobileSessionEnd").performClick() @@ -431,21 +444,27 @@ class ScannerEndToEndTest stage("admin-finished") } - private fun stage(name: String) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun stage(name: String) { InstrumentationRegistry.getInstrumentation().sendStatus(2, Bundle().apply { putString("qqqStage", name) }) } - private fun deviceStatus(id: String): String - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun deviceStatus(id: String): String { val rows = jsonRequest("/qqq/v1/table/mobileDevice/query", JSONObject()).getJSONArray("records") return (0 until rows.length()).map { rows.getJSONObject(it).getJSONObject("values") } .single { it.getString("id") == id }.getString("status") } + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun incompatibleServerStopsBeforeLogin() - { + fun incompatibleServerStopsBeforeLogin() { val context = InstrumentationRegistry.getInstrumentation().targetContext check(context.packageName == "io.qrun.mobile.scanner.e2e") val configFile = File(context.filesDir, "e2e.json") @@ -458,9 +477,11 @@ class ScannerEndToEndTest assertTrue("Incompatible servers must not display a login form", ui.onAllNodesWithText("Username").fetchSemanticsNodes().isEmpty()) } + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun loginEnrollApproveCaptureAndRevoke() - { + fun loginEnrollApproveCaptureAndRevoke() { val context = InstrumentationRegistry.getInstrumentation().targetContext check(context.packageName == "io.qrun.mobile.scanner.e2e") { "E2E must run in its isolated application" } val configFile = File(context.filesDir, "e2e.json") @@ -508,37 +529,53 @@ class ScannerEndToEndTest assertEquals("Revoked devices cannot save through an already-open process", records.length(), afterRevoke.length()) } - private fun waitFor(text: String, substring: Boolean = false) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun waitFor(text: String, substring: Boolean = false) { ui.waitUntil(timeoutMillis = 60_000) { ui.onAllNodesWithText(text, substring = substring).fetchSemanticsNodes().isNotEmpty() } } - private fun waitForTag(tag: String) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun waitForTag(tag: String) { ui.waitUntil(timeoutMillis = 60_000) { runCatching { ui.onNodeWithTag(tag).fetchSemanticsNode() }.isSuccess } } - private fun loginAdmin(password: String, account: String = "admin"): String - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun loginAdmin(password: String, account: String = "admin"): String { return SessionAcceptanceClient.login(origin, password, client, account) } - private fun runAdmin(process: String, deviceId: String) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun runAdmin(process: String, deviceId: String) { val launch = processCall(process, "/init", JSONObject()) val processId = launch.getString("processUUID") val applied = processCall(process, "/$processId/step/input", JSONObject().put("deviceId", deviceId)) - if(applied.optString("nextStep") == "result") processCall(process, "/$processId/step/result", JSONObject()) + if (applied.optString("nextStep") == "result") processCall(process, "/$processId/step/result", JSONObject()) } - private fun processCall(process: String, suffix: String, values: JSONObject): JSONObject - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun processCall(process: String, suffix: String, values: JSONObject): JSONObject { return SessionAcceptanceClient(origin, adminCookie, client).process(process, suffix, values) } + /******************************************************************************* + ** + *******************************************************************************/ private fun jsonRequest(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject = SessionAcceptanceClient(origin, adminCookie, client).json(path, body, expectedStatus) + /******************************************************************************* + ** + *******************************************************************************/ private fun executeJson(request: Request, expectedStatus: Int = 200): JSONObject = client.newCall(request).execute().use { response -> assertEquals("Test server request must succeed", expectedStatus, response.code) JSONObject(response.body!!.string()) diff --git a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/SessionAcceptanceClient.kt b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/SessionAcceptanceClient.kt index d34d514..478a9ef 100644 --- a/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/SessionAcceptanceClient.kt +++ b/scanner-host/src/androidTest/java/io/qrun/mobile/scanner/SessionAcceptanceClient.kt @@ -12,22 +12,36 @@ import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -/** Shared real semantic HTTP fixture operations; never prints credentials or response bodies. */ -internal class SessionAcceptanceClient(private val origin: String, private val cookie: String, - private val client: OkHttpClient) -{ - fun json(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject = execute(Request.Builder().url("$origin$path") - .header("Cookie", cookie).post(body.toString().toRequestBody("application/json".toMediaType())).build(), expectedStatus) +/******************************************************************************* + ** Shared real semantic HTTP fixture operations; never prints credentials or response bodies. + *******************************************************************************/ +internal class SessionAcceptanceClient( + private val origin: String, private val cookie: String, + private val client: OkHttpClient +) { + /******************************************************************************* + ** + *******************************************************************************/ + fun json(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject = execute( + Request.Builder().url("$origin$path") + .header("Cookie", cookie).post(body.toString().toRequestBody("application/json".toMediaType())).build(), expectedStatus + ) - fun process(name: String, suffix: String, values: JSONObject): JSONObject - { - var result = execute(Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", cookie) - .post(MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString()) - .addFormDataPart("stepTimeoutMillis", "10000").build()).build()) + /******************************************************************************* + ** + *******************************************************************************/ + fun process(name: String, suffix: String, values: JSONObject): JSONObject { + var result = execute( + Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", cookie) + .post( + MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString()) + .addFormDataPart("stepTimeoutMillis", "10000").build() + ).build() + ) val id = result.optString("processUUID") val job = result.optString("jobUUID") val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30) - while(result.optString("type") in listOf("JOB_STARTED", "RUNNING")) { + while (result.optString("type") in listOf("JOB_STARTED", "RUNNING")) { assertTrue("Fixture process must finish before its deadline", System.nanoTime() < deadline) Thread.sleep(50) result = execute(Request.Builder().url("$origin/qqq/v1/processes/$name/$id/status/$job").header("Cookie", cookie).build()) @@ -36,44 +50,69 @@ internal class SessionAcceptanceClient(private val origin: String, private val c return result } - fun apply(name: String, values: JSONObject) - { + /******************************************************************************* + ** + *******************************************************************************/ + fun apply(name: String, values: JSONObject) { val id = process(name, "/init", JSONObject()).getString("processUUID") val applied = process(name, "/$id/step/input", values) - if(applied.optString("nextStep") == "result") process(name, "/$id/step/result", JSONObject()) + if (applied.optString("nextStep") == "result") process(name, "/$id/step/result", JSONObject()) } - fun sessions(): List - { + /******************************************************************************* + ** + *******************************************************************************/ + fun sessions(): List { val rows = json("/qqq/v1/table/mobileSession/query", JSONObject()).getJSONArray("records") return (0 until rows.length()).map { rows.getJSONObject(it).getJSONObject("values") } } + /******************************************************************************* + ** + *******************************************************************************/ fun widget(id: String) = json("/qqq/v1/widget/mobileSessionScreen", JSONObject().put("id", id)) - fun logout() - { - client.newCall(Request.Builder().url("$origin/qqq/v1/logout").header("Cookie", cookie) - .post("{}".toRequestBody("application/json".toMediaType())).build()).execute().use { + /******************************************************************************* + ** + *******************************************************************************/ + fun logout() { + client.newCall( + Request.Builder().url("$origin/qqq/v1/logout").header("Cookie", cookie) + .post("{}".toRequestBody("application/json".toMediaType())).build() + ).execute().use { assertTrue("Fixture admin logout must succeed", it.isSuccessful) } } + /******************************************************************************* + ** + *******************************************************************************/ private fun execute(request: Request, expectedStatus: Int = 200) = client.newCall(request).execute().use { assertEquals("Fixture semantic request must succeed", expectedStatus, it.code) JSONObject(it.body!!.string()) } + /******************************************************************************* + ** + *******************************************************************************/ companion object { + /******************************************************************************* + ** + *******************************************************************************/ fun login(origin: String, password: String, client: OkHttpClient, account: String = "admin"): String = - client.newCall(Request.Builder().url("$origin/qqq/v1/manageSession") - .header("Authorization", Credentials.basic(account, password)) - .post("{}".toRequestBody("application/json".toMediaType())).build()).execute().use { + client.newCall( + Request.Builder().url("$origin/qqq/v1/manageSession") + .header("Authorization", Credentials.basic(account, password)) + .post("{}".toRequestBody("application/json".toMediaType())).build() + ).execute().use { assertEquals("Fixture admin login must succeed", 200, it.code) it.headers("Set-Cookie").first { value -> value.startsWith("sessionUUID=") }.substringBefore(';') } - fun image(value: Any?): String? = when(value) { + /******************************************************************************* + ** + *******************************************************************************/ + fun image(value: Any?): String? = when (value) { is String -> value.takeIf { it.startsWith("data:image/jpeg;base64,") } is JSONObject -> value.keys().asSequence().mapNotNull { image(value.opt(it)) }.firstOrNull() is org.json.JSONArray -> (0 until value.length()).firstNotNullOfOrNull { image(value.opt(it)) } diff --git a/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt b/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt index 7b6f591..005a8a8 100644 --- a/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt +++ b/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt @@ -9,26 +9,31 @@ import androidx.core.content.ContextCompat internal const val HARDWARE_SCAN_SOURCE = "e2e-fixture" -/** Disposable E2E package only. No debug/release source set contains this ingress. */ -internal fun createScannerDriver(context: Context): ScannerDriver = object : ScannerDriver -{ +/******************************************************************************* + ** Disposable E2E package only. No debug/release source set contains this ingress. + *******************************************************************************/ +internal fun createScannerDriver(context: Context): ScannerDriver = object : ScannerDriver { private var listener: ScannerDriver.Listener? = null private var registered = false private var pending = false - private val receiver = object : BroadcastReceiver() - { - override fun onReceive(context: Context?, intent: Intent?) - { - if(!pending || intent?.action != "io.qrun.mobile.scanner.SCAN") return + private val receiver = object : BroadcastReceiver() { + /******************************************************************************* + ** + *******************************************************************************/ + override fun onReceive(context: Context?, intent: Intent?) { + 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 + if (intent.getStringExtra("com.symbol.datawedge.decode_mode") == "multiple" || intent.hasExtra("com.symbol.datawedge.barcodes")) return pending = false listener?.scanned(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN") listener?.idle() } } - override fun open(listener: ScannerDriver.Listener) - { + + /******************************************************************************* + ** + *******************************************************************************/ + override fun open(listener: ScannerDriver.Listener) { this.listener = listener ContextCompat.registerReceiver(context, receiver, IntentFilter("io.qrun.mobile.scanner.SCAN").apply { addCategory(Intent.CATEGORY_DEFAULT) @@ -36,13 +41,34 @@ internal fun createScannerDriver(context: Context): ScannerDriver = object : Sca registered = true listener.idle() } + + /******************************************************************************* + ** + *******************************************************************************/ override fun isReadPending() = pending - override fun read() { pending = true } - override fun cancelRead() { pending = false; listener?.idle() } - override fun close() - { + + /******************************************************************************* + ** + *******************************************************************************/ + override fun read() { + pending = true + } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun cancelRead() { + pending = false; listener?.idle() + } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun close() { listener = null pending = false - if(registered) { context.unregisterReceiver(receiver); registered = false } + if (registered) { + context.unregisterReceiver(receiver); registered = false + } } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/AboutScreen.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/AboutScreen.kt new file mode 100644 index 0000000..a8b26a1 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/AboutScreen.kt @@ -0,0 +1,94 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import android.content.ActivityNotFoundException +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +/******************************************************************************* + ** Keep product information and public website links in secondary settings; + ** Query's bundled artwork remains visible without a server connection. + *******************************************************************************/ +@Composable +fun AboutButton() { + var visible by rememberSaveable { mutableStateOf(false) } + TextButton(onClick = { visible = true }) { Text("About QQQ Mobile") } + if (visible) { + Dialog(onDismissRequest = { visible = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) + { + Surface(Modifier.fillMaxSize()) + { + Column(Modifier.fillMaxSize().safeDrawingPadding()) + { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) + { + Text("About", Modifier.weight(1f), style = MaterialTheme.typography.titleLarge) + TextButton(onClick = { visible = false }) { Text("Back") } + } + Column( + Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) + { + Image(painterResource(R.drawable.query_mascot), "Query, the QRun mascot", Modifier.size(144.dp)) + Text("QQQ Mobile", style = MaterialTheme.typography.headlineSmall) + Text("Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + Text("Meet Query", style = MaterialTheme.typography.titleMedium) + Text("Query is the QRun mascot. Learn about Query and how we use QQQ ourselves.") + AboutWebsiteLink("Dogfooding at QRun", "https://www.qrun.io/dogfooding") + AboutWebsiteLink("Meet Query", "https://www.qrun.io/query") + OpenSourceLicensesButton() + } + } + } + } + } +} + + +/******************************************************************************* + ** Open only fixed public information URLs in the external browser, without + ** adding server addresses, session information or device identifiers. + *******************************************************************************/ +@Composable +private fun AboutWebsiteLink(label: String, url: String) { + val handler = LocalUriHandler.current + var unavailable by rememberSaveable { mutableStateOf(false) } + TextButton(onClick = { + unavailable = false + try { + handler.openUri(url) + } catch (exception: ActivityNotFoundException) { + unavailable = true + } catch (exception: IllegalArgumentException) { + unavailable = true + } + }) { Text(label) } + Text(url, style = MaterialTheme.typography.bodySmall) + if (unavailable) Text("No browser is available to open this link.", color = MaterialTheme.colorScheme.error) +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/AppScreenCapture.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/AppScreenCapture.kt index 436c273..8524cb2 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/AppScreenCapture.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/AppScreenCapture.kt @@ -15,38 +15,41 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -/** Copies only the caller's app-content rectangle, never the display or another app's window. */ +/******************************************************************************* + ** Copies only the caller's app-content rectangle, never the display or another app's window. + *******************************************************************************/ internal suspend fun captureAppScreen(window: Window, bounds: Rect, lease: ScreenLease): ScreenFrame? = withContext(Dispatchers.Main) { - if(bounds.width() <= 0 || bounds.height() <= 0 || !window.decorView.isAttachedToWindow || !window.decorView.hasWindowFocus()) return@withContext null + if (bounds.width() <= 0 || bounds.height() <= 0 || !window.decorView.isAttachedToWindow || !window.decorView.hasWindowFocus()) return@withContext null val scale = min(1.0, min(lease.maxWidth.toDouble() / bounds.width(), lease.maxHeight.toDouble() / bounds.height())) val width = (bounds.width() * scale).toInt().coerceAtLeast(1) val height = (bounds.height() * scale).toInt().coerceAtLeast(1) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val copied = suspendCancellableCoroutine { continuation -> - try - { + try { PixelCopy.request(window, Rect(bounds), bitmap, { result -> - if(continuation.isActive) continuation.resume(result == PixelCopy.SUCCESS) { _, _, _ -> bitmap.recycle() } + if (continuation.isActive) continuation.resume(result == PixelCopy.SUCCESS) { _, _, _ -> bitmap.recycle() } else bitmap.recycle() }, Handler(Looper.getMainLooper())) + } catch (exception: IllegalArgumentException) { + continuation.resume(false) } - catch(exception: IllegalArgumentException) { continuation.resume(false) } } - try - { - if(!copied) return@withContext null + try { + if (!copied) return@withContext null withContext(Dispatchers.Default) { var bytes: ByteArray? = null - for(quality in listOf(70, 50, 30)) - { + for (quality in listOf(70, 50, 30)) { val buffer = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, quality, buffer) - if(buffer.size() <= lease.maxBytes) { bytes = buffer.toByteArray(); break } + if (buffer.size() <= lease.maxBytes) { + bytes = buffer.toByteArray(); break + } } bytes?.let { ScreenFrame(it, width, height, Instant.now().toString()) } } + } finally { + if (!bitmap.isRecycled) bitmap.recycle() } - finally { if(!bitmap.isRecycled) bitmap.recycle() } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt index 692b3e2..7ddbbe5 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt @@ -16,13 +16,18 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject -/** The server already knows this installation, but this app never received its secret. */ +/******************************************************************************* + ** The server already knows this installation, but this app never received its secret. + *******************************************************************************/ class EnrollmentConflict : IllegalStateException("Registration already exists but its credentials were not received. Start a new registration and ask the administrator to approve it.") -/** The native QQQ session also authenticates device management. */ -class DeviceApi(private val server: String, private val credentials: DeviceCredentials, onRequestOutcome: (RequestOutcome) -> Unit = {}, - private val compatibility: UiApiCompatibility = UiApiCompatibility(server, requireMobileScanners = true)) -{ +/******************************************************************************* + ** The native QQQ session also authenticates device management. + *******************************************************************************/ +class DeviceApi( + private val server: String, private val credentials: DeviceCredentials, onRequestOutcome: (RequestOutcome) -> Unit = {}, + private val compatibility: UiApiCompatibility = UiApiCompatibility(server, requireMobileScanners = true) +) { var shortName: String = "" private set var displayName: String = "" @@ -30,30 +35,31 @@ class DeviceApi(private val server: String, private val credentials: DeviceCrede private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) .addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).build() - /** Check enrollment on demand; approval remains a server-side decision. */ + /******************************************************************************* + ** Check enrollment on demand; approval remains a server-side decision. + *******************************************************************************/ suspend fun status(session: String, displayName: String): String = withContext(Dispatchers.IO) { require(compatibility.server == server) { "Compatibility discovery must match the selected server" } compatibility.verify() val enrolling = credentials.deviceId.isBlank() - val request = Request.Builder().url(server.trimEnd('/') + MOBILE_SCANNERS_PATH + if(enrolling) "/devices/enroll" else "/devices/current") + val request = Request.Builder().url(server.trimEnd('/') + MOBILE_SCANNERS_PATH + if (enrolling) "/devices/enroll" else "/devices/current") .header("X-QQQ-Client", ANDROID_CLIENT) .header("Cookie", "sessionUUID=$session") credentials.headers().forEach { (name, value) -> request.header(name, value) } - if(enrolling) - { + if (enrolling) { val body = JSONObject().put("installationId", credentials.installationId).put("displayName", displayName) .put("manufacturer", Build.MANUFACTURER).put("model", Build.MODEL) .put("osVersion", Build.VERSION.RELEASE).put("appVersion", BuildConfig.VERSION_NAME) request.post(body.toString().toRequestBody("application/json".toMediaType())) } client.newCall(request.build()).execute().use { response -> - if(enrolling && response.code == 409) throw EnrollmentConflict() - if(!response.isSuccessful) throw UiApiException.from(response) + if (enrolling && response.code == 409) throw EnrollmentConflict() + if (!response.isSuccessful) throw UiApiException.from(response) val body = JSONObject(response.body?.string() ?: error("Empty device response")) shortName = body.optString("shortName").takeUnless { it == "null" }.orEmpty() this@DeviceApi.displayName = body.optString("displayName").takeUnless { it == "null" }.orEmpty() - if(enrolling) credentials.enroll(body) + if (enrolling) credentials.enroll(body) body.getString("status") } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceCredentials.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceCredentials.kt index ed89227..6e87561 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceCredentials.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceCredentials.kt @@ -15,66 +15,81 @@ import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec -/** Enrollment belongs to one server; its secret is encrypted by a non-exportable device key. */ -class DeviceCredentials(context: Context, server: String) -{ +/******************************************************************************* + ** Enrollment belongs to one server; its secret is encrypted by a non-exportable device key. + *******************************************************************************/ +class DeviceCredentials(context: Context, server: String) { private val scope = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) } private val file = File(context.noBackupFilesDir, "device-$scope.json") private val alias = "qqq-mobile-device" - private var values: JSONObject = if(file.exists()) decrypt(file.readText()) else JSONObject().put("installationId", UUID.randomUUID().toString()) + private var values: JSONObject = if (file.exists()) decrypt(file.readText()) else JSONObject().put("installationId", UUID.randomUUID().toString()) - init { if(!file.exists()) save() } + init { + if (!file.exists()) save() + } val installationId: String get() = values.getString("installationId") val deviceId: String get() = values.optString("deviceId") - /** Credentials are headers only and never become QQQ process inputs. */ - fun headers(): Map = if(deviceId.isBlank()) emptyMap() else mapOf( + /******************************************************************************* + ** Credentials are headers only and never become QQQ process inputs. + *******************************************************************************/ + fun headers(): Map = if (deviceId.isBlank()) emptyMap() else mapOf( "X-QQQ-Device-Id" to deviceId, "X-QQQ-Device-Token" to values.getString("deviceToken") ) - /** Save before exposing the enrollment result to UI. */ - fun enroll(response: JSONObject) - { + /******************************************************************************* + ** Save before exposing the enrollment result to UI. + *******************************************************************************/ + fun enroll(response: JSONObject) { values.put("deviceId", response.get("deviceId").toString()) values.put("deviceToken", response.getString("deviceToken")) save() } - /** A lost enrollment response requires a fresh identity and a new administrator approval. */ - fun restartPendingEnrollment() - { + /******************************************************************************* + ** A lost enrollment response requires a fresh identity and a new administrator approval. + *******************************************************************************/ + fun restartPendingEnrollment() { check(deviceId.isBlank()) { "An enrolled identity cannot be silently replaced" } values = JSONObject().put("installationId", UUID.randomUUID().toString()) save() } - /** Android Keystore owns the AES key across application launches. */ - private fun key(): SecretKey - { + /******************************************************************************* + ** Android Keystore owns the AES key across application launches. + *******************************************************************************/ + private fun key(): SecretKey { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } (keyStore.getKey(alias, null) as? SecretKey)?.let { return it } return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { - init(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build()) + init( + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build() + ) }.generateKey() } - /** Atomic replacement prevents a partially written token after interruption. */ - private fun save() - { + /******************************************************************************* + ** Atomic replacement prevents a partially written token after interruption. + *******************************************************************************/ + private fun save() { val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, key()) } val encrypted = cipher.doFinal(values.toString().toByteArray(Charsets.UTF_8)) val encoded = Base64.encodeToString(cipher.iv + encrypted, Base64.NO_WRAP) val atomicFile = android.util.AtomicFile(file) val stream = atomicFile.startWrite() - try { stream.write(encoded.toByteArray()); atomicFile.finishWrite(stream) } - catch(exception: Exception) { atomicFile.failWrite(stream); throw exception } + try { + stream.write(encoded.toByteArray()); atomicFile.finishWrite(stream) + } catch (exception: Exception) { + atomicFile.failWrite(stream); throw exception + } } - /** Corrupt storage fails closed instead of silently enrolling a replacement identity. */ - private fun decrypt(encoded: String): JSONObject - { + /******************************************************************************* + ** Corrupt storage fails closed instead of silently enrolling a replacement identity. + *******************************************************************************/ + private fun decrypt(encoded: String): JSONObject { val bytes = Base64.decode(encoded, Base64.NO_WRAP) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12))) diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenCaptureService.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenCaptureService.kt index d95809f..f73ef93 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenCaptureService.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenCaptureService.kt @@ -36,9 +36,10 @@ import kotlin.math.min import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** Nonexported, session-only projection. No consent, credentials or frames are persisted. */ -class DeviceScreenCaptureService : Service() -{ +/******************************************************************************* + ** Nonexported, session-only projection. No consent, credentials or frames are persisted. + *******************************************************************************/ +class DeviceScreenCaptureService : Service() { private val handler = Handler(Looper.getMainLooper()) private var owner: String? = null private var projection: MediaProjection? = null @@ -50,47 +51,69 @@ class DeviceScreenCaptureService : Service() private var foregroundReady = false private var screenReceiverRegistered = false private val stopReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { finishProjection() } + /******************************************************************************* + ** Screen-off invalidates projection immediately, even between leased frame polls. + *******************************************************************************/ + override fun onReceive(context: Context?, intent: Intent?) { + finishProjection() + } } private var projectionCallback: MediaProjection.Callback? = null - private val unboundTimeout = Runnable { if(owner == null) finishProjection() } + private val unboundTimeout = Runnable { if (owner == null) finishProjection() } private val leaseTimeout = Runnable { finishProjection() } + /******************************************************************************* + ** Expose the same-process service without exporting projection control to other applications. + *******************************************************************************/ internal inner class LocalBinder : Binder() { + /******************************************************************************* + ** Return the bound instance so the authenticated host can supply its memory-only owner ticket. + *******************************************************************************/ fun service() = this@DeviceScreenCaptureService } + /******************************************************************************* + ** Grant only the local binder; binding itself never adopts a projection consent result. + *******************************************************************************/ override fun onBind(intent: Intent): IBinder = LocalBinder() - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int - { - if(intent?.action == STOP) { finishProjection(); return START_NOT_STICKY } + /******************************************************************************* + ** Establish the visible sharing notification before accepting consent; never restart projection automatically. + *******************************************************************************/ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == STOP) { + finishProjection(); return START_NOT_STICKY + } try { - val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(NotificationChannel(CHANNEL, "Device screen sharing", NotificationManager.IMPORTANCE_LOW)) - if(!manager.areNotificationsEnabled() || manager.getNotificationChannel(CHANNEL).importance == NotificationManager.IMPORTANCE_NONE) - { + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(NotificationChannel(CHANNEL, "Device screen sharing", NotificationManager.IMPORTANCE_LOW)) + if (!manager.areNotificationsEnabled() || manager.getNotificationChannel(CHANNEL).importance == NotificationManager.IMPORTANCE_NONE) { + finishProjection() + return START_NOT_STICKY + } + val stop = PendingIntent.getService( + this, 0, Intent(this, DeviceScreenCaptureService::class.java).setAction(STOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val notification = Notification.Builder(this, CHANNEL).setSmallIcon(R.drawable.ic_scanner) + .setContentTitle("QQQ device screen sharing").setContentText("Other apps and notifications may be visible to your administrator.") + .setOngoing(true).addAction(Notification.Action.Builder(null, "Stop sharing", stop).build()).build() + if (Build.VERSION.SDK_INT >= 29) startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) + else startForeground(NOTIFICATION_ID, notification) + foregroundReady = true + handler.removeCallbacks(unboundTimeout) + handler.postDelayed(unboundTimeout, 5000) + } catch (exception: Exception) { finishProjection() - return START_NOT_STICKY } - val stop = PendingIntent.getService(this, 0, Intent(this, DeviceScreenCaptureService::class.java).setAction(STOP), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val notification = Notification.Builder(this, CHANNEL).setSmallIcon(R.drawable.ic_scanner) - .setContentTitle("QQQ device screen sharing").setContentText("Other apps and notifications may be visible to your administrator.") - .setOngoing(true).addAction(Notification.Action.Builder(null, "Stop sharing", stop).build()).build() - if(Build.VERSION.SDK_INT >= 29) startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) - else startForeground(NOTIFICATION_ID, notification) - foregroundReady = true - handler.removeCallbacks(unboundTimeout) - handler.postDelayed(unboundTimeout, 5000) - } catch(exception: Exception) { finishProjection() } return START_NOT_STICKY } - /** Called only by our same-process binder after a freshly approved system result. */ - internal fun begin(grantOwner: String, resultCode: Int, data: Intent, onStopped: () -> Unit): Boolean - { - if(!foregroundReady || owner != null || resultCode != Activity.RESULT_OK || getSystemService(KeyguardManager::class.java).isDeviceLocked) return false + /******************************************************************************* + ** Called only by our same-process binder after a freshly approved system result. + *******************************************************************************/ + internal fun begin(grantOwner: String, resultCode: Int, data: Intent, onStopped: () -> Unit): Boolean { + if (!foregroundReady || owner != null || resultCode != Activity.RESULT_OK || getSystemService(KeyguardManager::class.java).isDeviceLocked) return false owner = grantOwner stopped = onStopped handler.removeCallbacks(unboundTimeout) @@ -98,9 +121,18 @@ class DeviceScreenCaptureService : Service() val granted = requireNotNull(getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)) projection = granted val callback = object : MediaProjection.Callback() { - override fun onStop() { if(projection === granted) finishProjection() } + /******************************************************************************* + ** A revoked projection ends only the matching grant, so a late callback cannot stop its replacement. + *******************************************************************************/ + override fun onStop() { + if (projection === granted) finishProjection() + } + + /******************************************************************************* + ** Ignore obsolete projection callbacks and resize only positive dimensions from the active grant. + *******************************************************************************/ override fun onCapturedContentResize(width: Int, height: Int) { - if(projection === granted && width > 0 && height > 0) resize(width, height) + if (projection === granted && width > 0 && height > 0) resize(width, height) } } projectionCallback = callback @@ -108,43 +140,61 @@ class DeviceScreenCaptureService : Service() val (width, height) = displaySize() val (scaledWidth, scaledHeight) = scaledSize(width, height) reader = newReader(scaledWidth, scaledHeight) - display = requireNotNull(granted.createVirtualDisplay("QQQ consented device screen", scaledWidth, scaledHeight, - resources.configuration.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, reader!!.surface, null, handler)) + display = requireNotNull( + granted.createVirtualDisplay( + "QQQ consented device screen", scaledWidth, scaledHeight, + resources.configuration.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, reader!!.surface, null, handler + ) + ) ContextCompat.registerReceiver(this, stopReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF), ContextCompat.RECEIVER_NOT_EXPORTED) screenReceiverRegistered = true handler.postDelayed(leaseTimeout, 30000) true - } catch(exception: Exception) { finishProjection(); false } + } catch (exception: Exception) { + finishProjection(); false + } } - internal fun stop(grantOwner: String) { if(owner == grantOwner) finishProjection() } + /******************************************************************************* + ** Allow only the authenticated host lifetime that owns the grant to stop its projection. + *******************************************************************************/ + internal fun stop(grantOwner: String) { + if (owner == grantOwner) finishProjection() + } - internal fun boundLease(grantOwner: String, expiresAt: Long) - { - if(owner != grantOwner || projection == null) return + /******************************************************************************* + ** Bound projection lifetime to the current server lease using the monotonic device clock. + *******************************************************************************/ + internal fun boundLease(grantOwner: String, expiresAt: Long) { + if (owner != grantOwner || projection == null) return handler.removeCallbacks(leaseTimeout) val remaining = expiresAt - SystemClock.elapsedRealtime() - if(remaining <= 0) finishProjection() else handler.postDelayed(leaseTimeout, remaining.coerceAtMost(30000)) + if (remaining <= 0) finishProjection() else handler.postDelayed(leaseTimeout, remaining.coerceAtMost(30000)) } + /******************************************************************************* + ** Copy only the owning grant's current pixels and enforce lease dimensions and encoded-byte limits. + *******************************************************************************/ internal suspend fun frame(grantOwner: String, lease: ScreenLease): ScreenFrame? = withContext(Dispatchers.Main) { - if(owner != grantOwner || projection == null || lease.captureMode != CaptureMode.DEVICE_SCREEN) return@withContext null + if (owner != grantOwner || projection == null || lease.captureMode != CaptureMode.DEVICE_SCREEN) return@withContext null val capturedProjection = projection - if(getSystemService(KeyguardManager::class.java).isDeviceLocked) { finishProjection(); return@withContext null } + if (getSystemService(KeyguardManager::class.java).isDeviceLocked) { + finishProjection(); return@withContext null + } val image = latestImage.image ?: return@withContext null val capturedAt = latestCapturedAt // The main-thread listener cannot replace this image while its pixels are copied. val bitmap = run { val plane = image.planes.single() - if(plane.pixelStride != 4 || plane.rowStride < image.width * 4 || plane.rowStride.toLong() * image.height > 8388608L) + if (plane.pixelStride != 4 || plane.rowStride < image.width * 4 || plane.rowStride.toLong() * image.height > 8388608L) return@withContext null val source = plane.buffer.duplicate() val rowBytes = image.width * 4 - if(source.remaining().toLong() < plane.rowStride.toLong() * (image.height - 1) + rowBytes) return@withContext null + if (source.remaining().toLong() < plane.rowStride.toLong() * (image.height - 1) + rowBytes) return@withContext null val packed = java.nio.ByteBuffer.allocate(rowBytes * image.height) val origin = source.position() - for(row in 0 until image.height) { + for (row in 0 until image.height) { val slice = source.duplicate() slice.position(origin + row * plane.rowStride) slice.limit(slice.position() + rowBytes) @@ -155,50 +205,64 @@ class DeviceScreenCaptureService : Service() try { copied.copyPixelsFromBuffer(packed) copied - } catch(exception: Exception) { copied.recycle(); throw exception } + } catch (exception: Exception) { + copied.recycle(); throw exception + } } try { - if(bitmap.width > lease.maxWidth || bitmap.height > lease.maxHeight) return@withContext null + if (bitmap.width > lease.maxWidth || bitmap.height > lease.maxHeight) return@withContext null withContext(Dispatchers.Default) { var frame: ScreenFrame? = null - for(quality in listOf(70, 50, 30)) { + for (quality in listOf(70, 50, 30)) { val bytes = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, quality, bytes) - if(bytes.size() <= lease.maxBytes) { + if (bytes.size() <= lease.maxBytes) { frame = ScreenFrame(bytes.toByteArray(), bitmap.width, bitmap.height, capturedAt, CaptureMode.DEVICE_SCREEN) break } } frame }.takeIf { owner == grantOwner && projection === capturedProjection } - } finally { bitmap.recycle() } + } finally { + bitmap.recycle() + } } - override fun onConfigurationChanged(newConfig: Configuration) - { + /******************************************************************************* + ** Recreate the reader dimensions for the current display while retaining the same consent grant. + *******************************************************************************/ + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - if(projection != null) { val (width, height) = displaySize(); resize(width, height) } + if (projection != null) { + val (width, height) = displaySize(); resize(width, height) + } } - private fun displaySize(): Pair - { + /******************************************************************************* + ** Use full display bounds so projection scaling is independent of the current application window. + *******************************************************************************/ + private fun displaySize(): Pair { val metrics = getSystemService(WindowManager::class.java) - if(Build.VERSION.SDK_INT >= 30) return metrics.maximumWindowMetrics.bounds.let { it.width() to it.height() } + if (Build.VERSION.SDK_INT >= 30) return metrics.maximumWindowMetrics.bounds.let { it.width() to it.height() } @Suppress("DEPRECATION") val size = android.util.DisplayMetrics().also { metrics.defaultDisplay.getRealMetrics(it) } return size.widthPixels to size.heightPixels } - private fun scaledSize(width: Int, height: Int): Pair - { + /******************************************************************************* + ** Preserve aspect ratio within the client's maximum capture dimensions without enlarging pixels. + *******************************************************************************/ + private fun scaledSize(width: Int, height: Int): Pair { val scale = min(1.0, min(640.0 / width, 1280.0 / height)) return (width * scale).toInt().coerceAtLeast(1) to (height * scale).toInt().coerceAtLeast(1) } - private fun resize(width: Int, height: Int) - { + /******************************************************************************* + ** Replace the reader surface without retaining the previous image; any failure ends projection. + *******************************************************************************/ + private fun resize(width: Int, height: Int) { val current = display ?: return val (targetWidth, targetHeight) = scaledSize(width, height) - if(reader?.width == targetWidth && reader?.height == targetHeight) return + if (reader?.width == targetWidth && reader?.height == targetHeight) return val previous = reader try { latestImage.close() @@ -206,30 +270,38 @@ class DeviceScreenCaptureService : Service() reader = replacement current.surface = replacement.surface current.resize(targetWidth, targetHeight, resources.configuration.densityDpi) - } catch(exception: Exception) { finishProjection() } - finally { runCatching { previous?.close() } } + } catch (exception: Exception) { + finishProjection() + } finally { + runCatching { previous?.close() } + } } - /** Drain continuously so first demand gets current pixels; encode only on leased polls. */ - private fun newReader(width: Int, height: Int): ImageReader - { + /******************************************************************************* + ** Drain continuously so first demand gets current pixels; encode only on leased polls. + *******************************************************************************/ + private fun newReader(width: Int, height: Int): ImageReader { // One retained image plus two free slots lets acquireLatestImage discard older queued frames. return ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 3).also { created -> created.setOnImageAvailableListener({ source -> - if(source === reader && projection != null) { + if (source === reader && projection != null) { try { source.acquireLatestImage()?.let { image -> latestImage.replace(image) latestCapturedAt = Instant.now().toString() } - } catch(exception: Exception) { finishProjection() } + } catch (exception: Exception) { + finishProjection() + } } }, handler) } } - private fun finishProjection() - { + /******************************************************************************* + ** Invalidate ownership before releasing every projection resource and notifying its host once. + *******************************************************************************/ + private fun finishProjection() { val callback = stopped stopped = null owner = null @@ -238,12 +310,14 @@ class DeviceScreenCaptureService : Service() val prior = projection projection = null foregroundReady = false - if(screenReceiverRegistered) { runCatching { unregisterReceiver(stopReceiver) }; screenReceiverRegistered = false } + if (screenReceiverRegistered) { + runCatching { unregisterReceiver(stopReceiver) }; screenReceiverRegistered = false + } runCatching { display?.release() }; display = null runCatching { latestImage.close() } latestCapturedAt = "" runCatching { reader?.close() }; reader = null - projectionCallback?.let { callback -> runCatching { prior?.unregisterCallback(callback) } } + projectionCallback?.let { projectionListener -> runCatching { prior?.unregisterCallback(projectionListener) } } projectionCallback = null runCatching { prior?.stop() } runCatching { stopForeground(STOP_FOREGROUND_REMOVE) } @@ -251,9 +325,23 @@ class DeviceScreenCaptureService : Service() callback?.invoke() } - override fun onDestroy() { finishProjection(); super.onDestroy() } - override fun onTaskRemoved(rootIntent: Intent?) { finishProjection(); super.onTaskRemoved(rootIntent) } + /******************************************************************************* + ** Release projection resources before Android destroys the service, including partially initialized grants. + *******************************************************************************/ + override fun onDestroy() { + finishProjection(); super.onDestroy() + } + /******************************************************************************* + ** Removing the host task revokes the grant instead of leaving capture running in the background. + *******************************************************************************/ + override fun onTaskRemoved(rootIntent: Intent?) { + finishProjection(); super.onTaskRemoved(rootIntent) + } + + /******************************************************************************* + ** Keep notification and stop-action identifiers private to the nonexported projection service. + *******************************************************************************/ private companion object { const val CHANNEL = "qqq-device-screen" const val STOP = "io.qrun.mobile.scanner.STOP_DEVICE_SCREEN" diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenSharing.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenSharing.kt index 56feb10..a427bea 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenSharing.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceScreenSharing.kt @@ -28,9 +28,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import java.util.UUID -/** A consent attempt belongs to one authenticated host lifetime, never a saved preference. */ -internal class DeviceScreenSharing(private val context: Context) -{ +/******************************************************************************* + ** A consent attempt belongs to one authenticated host lifetime, never a saved preference. + *******************************************************************************/ +internal class DeviceScreenSharing(private val context: Context) { var active by mutableStateOf(false) private set var pending by mutableStateOf(false) @@ -46,19 +47,23 @@ internal class DeviceScreenSharing(private val context: Context) var onSystemStop: () -> Unit = {} var onStarted: () -> Unit = {} - fun beginConsent(): String - { + /******************************************************************************* + ** Revoke any previous grant before creating a fresh ticket for Android's consent result. + *******************************************************************************/ + fun beginConsent(): String { stop() pending = true message = "" return consentAttempt.begin() } - fun accept(ticket: String, resultCode: Int, data: Intent?, allowed: () -> Boolean) - { - if(!consentAttempt.current(ticket) || !pending) return + /******************************************************************************* + ** Consume consent only for the current authenticated host, then recheck permission when binding completes. + *******************************************************************************/ + fun accept(ticket: String, resultCode: Int, data: Intent?, allowed: () -> Boolean) { + if (!consentAttempt.current(ticket) || !pending) return pending = false - if(!consentAttempt.consume(ticket, resultCode == Activity.RESULT_OK && data != null, allowed())) { + if (!consentAttempt.consume(ticket, resultCode == Activity.RESULT_OK && data != null, allowed())) { message = "Device screen sharing was not started." return } @@ -66,37 +71,77 @@ internal class DeviceScreenSharing(private val context: Context) connecting = true val serviceIntent = Intent(context, DeviceScreenCaptureService::class.java) val binding = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, binder: IBinder) - { - if(!consentAttempt.current(ticket) || connection !== this) { runCatching { context.unbindService(this) }; return } - if(!allowed()) { stop(); return } + /******************************************************************************* + ** Reject stale bindings and recheck host eligibility before handing Android consent to the service. + *******************************************************************************/ + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + if (!consentAttempt.current(ticket) || connection !== this) { + runCatching { context.unbindService(this) }; return + } + if (!allowed()) { + stop(); return + } val current = (binder as DeviceScreenCaptureService.LocalBinder).service() service = current active = current.begin(owner, resultCode, data) { - if(consentAttempt.current(ticket)) { stop(); onSystemStop() } + if (consentAttempt.current(ticket)) { + stop(); onSystemStop() + } } - if(active) onStarted() + if (active) onStarted() connecting = false - if(!active) { stop(); message = "Device screen sharing could not start. Try again from the app." } + if (!active) { + stop(); message = "Device screen sharing could not start. Try again from the app." + } + } + + /******************************************************************************* + ** Invalidate the current binding and notify its host when Android removes the projection service. + *******************************************************************************/ + private fun disconnected() { + if (connection === this) { + stop(); onSystemStop() + } + } + + /******************************************************************************* + ** Treat service loss as revoked sharing rather than automatically reconnecting without consent. + *******************************************************************************/ + override fun onServiceDisconnected(name: ComponentName) { + disconnected() + } + + /******************************************************************************* + ** A dead binding invalidates this host's projection and requires a new operator action. + *******************************************************************************/ + override fun onBindingDied(name: ComponentName) { + disconnected() + } + + /******************************************************************************* + ** A rejected service binding ends the attempt rather than retaining a pending consent state. + *******************************************************************************/ + override fun onNullBinding(name: ComponentName) { + disconnected() } - private fun disconnected() { if(connection === this) { stop(); onSystemStop() } } - override fun onServiceDisconnected(name: ComponentName) { disconnected() } - override fun onBindingDied(name: ComponentName) { disconnected() } - override fun onNullBinding(name: ComponentName) { disconnected() } } connection = binding try { ContextCompat.startForegroundService(context, serviceIntent) - if(!context.bindService(serviceIntent, binding, Context.BIND_AUTO_CREATE)) { + if (!context.bindService(serviceIntent, binding, Context.BIND_AUTO_CREATE)) { connection = null stop() message = "Device screen sharing could not start." } - } catch(exception: Exception) { stop(); message = "Device screen sharing could not start. Check notification permission." } + } catch (exception: Exception) { + stop(); message = "Device screen sharing could not start. Check notification permission." + } } - fun stop() - { + /******************************************************************************* + ** Invalidate the consent ticket before unbinding so late results cannot resume sharing. + *******************************************************************************/ + fun stop() { consentAttempt.invalidate() active = false pending = false @@ -106,17 +151,27 @@ internal class DeviceScreenSharing(private val context: Context) val binding = connection connection = null previous?.stop(owner) - if(binding != null) runCatching { context.unbindService(binding) } + if (binding != null) runCatching { context.unbindService(binding) } } - suspend fun frame(lease: ScreenLease): ScreenFrame? = if(active) service?.frame(owner, lease) else null - fun boundLease(expiresAt: Long) { if(active) service?.boundLease(owner, expiresAt) } + /******************************************************************************* + ** Delegate capture only while this host still owns an active projection. + *******************************************************************************/ + suspend fun frame(lease: ScreenLease): ScreenFrame? = if (active) service?.frame(owner, lease) else null + + /******************************************************************************* + ** Pass the authenticated viewer's deadline only to this host's active projection grant. + *******************************************************************************/ + fun boundLease(expiresAt: Long) { + if (active) service?.boundLease(owner, expiresAt) + } } -/** Only a deliberate device operator action launches Android's fresh consent dialog. */ +/******************************************************************************* + ** Only a deliberate device operator action launches Android's fresh consent dialog. + *******************************************************************************/ @Composable -internal fun DeviceScreenSharingControl(sharing: DeviceScreenSharing, controller: MobileSessionController, allowed: () -> Boolean) -{ +internal fun DeviceScreenSharingControl(sharing: DeviceScreenSharing, controller: MobileSessionController, allowed: () -> Boolean) { val context = LocalContext.current val currentAllowed by rememberUpdatedState(allowed) var disclosure by remember { mutableStateOf(false) } @@ -126,33 +181,38 @@ internal fun DeviceScreenSharingControl(sharing: DeviceScreenSharing, controller val consent = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val currentTicket = ticket ticket = null - if(currentTicket != null) sharing.accept(currentTicket, result.resultCode, result.data) { + if (currentTicket != null) sharing.accept(currentTicket, result.resultCode, result.data) { currentAllowed() && controller.supportsDeviceScreen } } - if(sharing.active) { + if (sharing.active) { Text("Sharing this device screen, including other apps and notifications.") TextButton(onClick = { controller.pauseViewing() }) { Text("Stop device screen sharing") } - } else if(controller.supportsDeviceScreen) { - val canNotify = remember(permissionRevision) { Build.VERSION.SDK_INT < 33 || - ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } - if(!canNotify) TextButton(onClick = { if(Build.VERSION.SDK_INT >= 33) notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }) { + } else if (controller.supportsDeviceScreen) { + val canNotify = remember(permissionRevision) { + Build.VERSION.SDK_INT < 33 || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } + if (!canNotify) TextButton(onClick = { if (Build.VERSION.SDK_INT >= 33) notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }) { Text("Enable sharing notification") } else TextButton(enabled = !sharing.pending && !sharing.connecting && allowed(), onClick = { disclosure = true }) { Text("Share device screen…") } - if(sharing.connecting) Text("Starting device screen sharing…") - if(sharing.message.isNotBlank()) Text(sharing.message) + if (sharing.connecting) Text("Starting device screen sharing…") + if (sharing.message.isNotBlank()) Text(sharing.message) } - if(disclosure) AlertDialog(onDismissRequest = { disclosure = false }, title = { Text("Share this device screen?") }, + if (disclosure) AlertDialog( + onDismissRequest = { disclosure = false }, title = { Text("Share this device screen?") }, text = { Text("An authorized administrator can see other apps and notifications while viewing this session. Android will ask for permission. Sharing stops if the viewer disconnects, you sign out, or this activity restarts. Use Stop sharing in the notification or Android's sharing indicator at any time.") }, dismissButton = { TextButton(onClick = { disclosure = false }) { Text("Cancel") } }, - confirmButton = { TextButton(onClick = { - disclosure = false - if(currentAllowed() && controller.supportsDeviceScreen && context.getSystemService(NotificationManager::class.java).areNotificationsEnabled()) { - ticket = sharing.beginConsent() - val manager = context.getSystemService(MediaProjectionManager::class.java) - val intent = if(Build.VERSION.SDK_INT >= 34) manager.createScreenCaptureIntent(MediaProjectionConfig.createConfigForDefaultDisplay()) + confirmButton = { + TextButton(onClick = { + disclosure = false + if (currentAllowed() && controller.supportsDeviceScreen && context.getSystemService(NotificationManager::class.java).areNotificationsEnabled()) { + ticket = sharing.beginConsent() + val manager = context.getSystemService(MediaProjectionManager::class.java) + val intent = if (Build.VERSION.SDK_INT >= 34) manager.createScreenCaptureIntent(MediaProjectionConfig.createConfigForDefaultDisplay()) else manager.createScreenCaptureIntent() - consent.launch(intent) - } - }) { Text("Continue to Android permission") } }) + consent.launch(intent) + } + }) { Text("Continue to Android permission") } + }) } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DiagnosticPresentation.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DiagnosticPresentation.kt index 64099cd..fa72f37 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DiagnosticPresentation.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DiagnosticPresentation.kt @@ -5,14 +5,19 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -/** Presentation accepts only the fixed, credential-free event model, never raw HTTP text. */ +/******************************************************************************* + ** Presentation accepts only the fixed, credential-free event model, never raw HTTP text. + *******************************************************************************/ internal fun DiagnosticEvent.isProblem(): Boolean = httpStatus?.let { it !in 200..299 } == true || code in setOf( DiagnosticCode.COMPATIBILITY_FAILED, DiagnosticCode.BRAND_DEFAULT, DiagnosticCode.LOGO_UNAVAILABLE, DiagnosticCode.CONNECTION_FAILED, DiagnosticCode.NATIVE_TRANSPORT_FAILED, - DiagnosticCode.DEVICE_TRANSPORT_FAILED, DiagnosticCode.SESSION_TRANSPORT_FAILED) + DiagnosticCode.DEVICE_TRANSPORT_FAILED, DiagnosticCode.SESSION_TRANSPORT_FAILED +) -internal fun DiagnosticEvent.title(): String = when(code) -{ +/******************************************************************************* + ** Translate fixed event categories into operator language without including raw server text. + *******************************************************************************/ +internal fun DiagnosticEvent.title(): String = when (code) { DiagnosticCode.COMPATIBILITY_RESPONSE -> "Server compatibility checked" DiagnosticCode.COMPATIBILITY_FAILED -> "Server compatibility check failed" DiagnosticCode.BRAND_LOADED -> "Server appearance loaded" @@ -21,47 +26,59 @@ internal fun DiagnosticEvent.title(): String = when(code) DiagnosticCode.LOGO_UNAVAILABLE -> "Server logo unavailable" DiagnosticCode.CONNECTION_OK -> "Server reachable" DiagnosticCode.CONNECTION_FAILED -> "Connection check failed" - DiagnosticCode.NATIVE_RESPONSE -> if(isProblem()) "App request failed" else "App request completed" + DiagnosticCode.NATIVE_RESPONSE -> if (isProblem()) "App request failed" else "App request completed" DiagnosticCode.NATIVE_TRANSPORT_FAILED -> "App request could not connect" - DiagnosticCode.DEVICE_RESPONSE -> if(isProblem()) "Device request failed" else "Device request completed" + DiagnosticCode.DEVICE_RESPONSE -> if (isProblem()) "Device request failed" else "Device request completed" DiagnosticCode.DEVICE_TRANSPORT_FAILED -> "Device request could not connect" - DiagnosticCode.SESSION_RESPONSE -> if(isProblem()) "Session request failed" else "Session request completed" + DiagnosticCode.SESSION_RESPONSE -> if (isProblem()) "Session request failed" else "Session request completed" DiagnosticCode.SESSION_TRANSPORT_FAILED -> "Session request could not connect" } -internal fun DiagnosticEvent.detail(): String -{ - val result = when(httpStatus) - { +/******************************************************************************* + ** Explain status codes and duration using the credential-free event model alone. + *******************************************************************************/ +internal fun DiagnosticEvent.detail(): String { + val result = when (httpStatus) { null -> "No HTTP response" 401 -> "HTTP 401 · Sign-in required or expired" 403 -> "HTTP 403 · Request not permitted" in 500..599 -> "HTTP $httpStatus · Server error" else -> "HTTP $httpStatus" } - val validation = if(code == DiagnosticCode.CONNECTION_FAILED && httpStatus in 200..299) " · Unexpected status response" else "" + val validation = if (code == DiagnosticCode.CONNECTION_FAILED && httpStatus in 200..299) " · Unexpected status response" else "" return "$result$validation · ${elapsedMillis} ms" } +/******************************************************************************* + ** Render the event instant in the selected zone without changing its stored ordering. + *******************************************************************************/ internal fun DiagnosticEvent.localTime(zone: ZoneId = ZoneId.systemDefault()): String = DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withZone(zone).format(time) +/******************************************************************************* + ** Present newest activity first while retaining only actionable categories when filtering problems. + *******************************************************************************/ internal fun diagnosticRows(events: List, problemsOnly: Boolean): List = events.asReversed().filter { !problemsOnly || it.isProblem() } +/******************************************************************************* + ** Select explicit reachability checks rather than inferring connectivity from authentication requests. + *******************************************************************************/ internal fun latestConnectionCheck(events: List): DiagnosticEvent? = events.lastOrNull { it.code == DiagnosticCode.CONNECTION_OK || it.code == DiagnosticCode.CONNECTION_FAILED } -internal fun diagnosticReport(events: List, problemsOnly: Boolean, zone: ZoneId = ZoneId.systemDefault()): String -{ +/******************************************************************************* + ** Build a shareable report solely from bounded, credential-free diagnostic records. + *******************************************************************************/ +internal fun diagnosticReport(events: List, problemsOnly: Boolean, zone: ZoneId = ZoneId.systemDefault()): String { val rows = diagnosticRows(events, problemsOnly) val date = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(zone) return buildString { appendLine("QQQ Mobile connection diagnostics") appendLine("Connection checks measure server reachability only; sign-in and device approval are separate.") - appendLine(if(problemsOnly) "Problems · newest first" else "All events · newest first") - if(rows.isEmpty()) append("No events.") + appendLine(if (problemsOnly) "Problems · newest first" else "All events · newest first") + if (rows.isEmpty()) append("No events.") rows.forEach { appendLine("${date.format(it.time)} · ${it.title()} · ${it.detail()}") } }.trimEnd() } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DiagnosticScreen.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DiagnosticScreen.kt index 44d6f7e..09a94b2 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DiagnosticScreen.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DiagnosticScreen.kt @@ -3,11 +3,34 @@ package io.qrun.mobile.scanner import android.content.ActivityNotFoundException import android.content.Intent -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -20,10 +43,11 @@ import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.delay import kotlinx.coroutines.launch -/** A dedicated window keeps process input blocked while diagnostics are being inspected. */ +/******************************************************************************* + ** A dedicated window keeps process input blocked while diagnostics are being inspected. + *******************************************************************************/ @Composable -internal fun DiagnosticScreen(log: DiagnosticLog, onBack: () -> Unit) -{ +internal fun DiagnosticScreen(log: DiagnosticLog, onBack: () -> Unit) { var events by remember(log) { mutableStateOf(log.snapshot()) } var paused by remember(log) { mutableStateOf(false) } var problemsOnly by remember(log) { mutableStateOf(false) } @@ -33,8 +57,7 @@ internal fun DiagnosticScreen(log: DiagnosticLog, onBack: () -> Unit) val scope = rememberCoroutineScope() LaunchedEffect(log, paused) { - while(!paused) - { + while (!paused) { events = log.snapshot() delay(1000) } @@ -70,15 +93,17 @@ internal fun DiagnosticScreen(log: DiagnosticLog, onBack: () -> Unit) FilterChip(selected = problemsOnly, onClick = { problemsOnly = true }, label = { Text("Problems") }) } OutlinedButton(onClick = { - if(!paused) events = log.snapshot() + if (!paused) events = log.snapshot() paused = !paused - }) { Text(if(paused) "Resume live updates" else "Pause updates") } + }) { Text(if (paused) "Resume live updates" else "Pause updates") } } } - if(rows.isEmpty()) item() + if (rows.isEmpty()) item() { - Text(if(problemsOnly && events.isNotEmpty()) "No problems in the recorded activity." else "No activity recorded yet. New events appear here while this screen is live.", - style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(vertical = 24.dp)) + Text( + if (problemsOnly && events.isNotEmpty()) "No problems in the recorded activity." else "No activity recorded yet. New events appear here while this screen is live.", + style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(vertical = 24.dp) + ) } items(rows) { event -> DiagnosticEventRow(event) } item() @@ -96,8 +121,11 @@ internal fun DiagnosticScreen(log: DiagnosticLog, onBack: () -> Unit) TextButton(modifier = Modifier.weight(1f), enabled = rows.isNotEmpty(), onClick = { val intent = Intent(Intent.ACTION_SEND).setType("text/plain") .putExtra(Intent.EXTRA_TEXT, diagnosticReport(events, problemsOnly)) - try { context.startActivity(Intent.createChooser(intent, "Share diagnostics")) } - catch(exception: ActivityNotFoundException) { scope.launch { snackbar.showSnackbar("No app is available to share diagnostics") } } + try { + context.startActivity(Intent.createChooser(intent, "Share diagnostics")) + } catch (exception: ActivityNotFoundException) { + scope.launch { snackbar.showSnackbar("No app is available to share diagnostics") } + } }) { Text("Share") } TextButton(modifier = Modifier.weight(1f), enabled = events.isNotEmpty(), onClick = { log.clear() @@ -111,36 +139,45 @@ internal fun DiagnosticScreen(log: DiagnosticLog, onBack: () -> Unit) } } +/******************************************************************************* + ** Separate explicit server reachability from sign-in and device approval in the operator summary. + *******************************************************************************/ @Composable -private fun ConnectionSummary(events: List, paused: Boolean) -{ +private fun ConnectionSummary(events: List, paused: Boolean) { val check = latestConnectionCheck(events) val reachable = check?.code == DiagnosticCode.CONNECTION_OK - Card(colors = CardDefaults.cardColors(containerColor = if(check != null && !reachable) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant)) + Card(colors = CardDefaults.cardColors(containerColor = if (check != null && !reachable) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant)) { Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("Server connection", style = MaterialTheme.typography.labelLarge) - Text(when { check == null -> "No check recorded"; reachable -> "Reachable"; else -> "Check failed" }, - style = MaterialTheme.typography.headlineSmall) - if(check != null) - { + Text( + when { + check == null -> "No check recorded"; reachable -> "Reachable"; else -> "Check failed" + }, + style = MaterialTheme.typography.headlineSmall + ) + if (check != null) { Text("Last check ${check.localTime()} · ${check.elapsedMillis} ms", style = MaterialTheme.typography.bodyMedium) } - Text(if(paused) "Updates paused · Showing a saved snapshot" else "Live · Activity updates automatically", style = MaterialTheme.typography.bodySmall) + Text(if (paused) "Updates paused · Showing a saved snapshot" else "Live · Activity updates automatically", style = MaterialTheme.typography.bodySmall) Text("Reachability only. Sign-in and device approval are checked separately.", style = MaterialTheme.typography.bodySmall) } } } +/******************************************************************************* + ** Render one safe event consistently with copied and shared diagnostic reports. + *******************************************************************************/ @Composable -private fun DiagnosticEventRow(event: DiagnosticEvent) -{ +private fun DiagnosticEventRow(event: DiagnosticEvent) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(event.localTime(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(event.title(), style = MaterialTheme.typography.titleSmall, - color = if(event.isProblem()) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface) + Text( + event.title(), style = MaterialTheme.typography.titleSmall, + color = if (event.isProblem()) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + ) Text(event.detail(), style = MaterialTheme.typography.bodyMedium) HorizontalDivider(Modifier.padding(top = 8.dp)) } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ForegroundLocation.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ForegroundLocation.kt index 4ea637b..cfb88ce 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ForegroundLocation.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ForegroundLocation.kt @@ -33,12 +33,20 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import java.time.Instant +/******************************************************************************* + ** Represent availability separately from a fix so disabled or denied reporting cannot reuse stale coordinates. + *******************************************************************************/ internal data class LocationReport(val status: String = "UNAVAILABLE", val fix: LocationFix? = null) + +/******************************************************************************* + ** Carry only the foreground provider fix and its accuracy/time for the session heartbeat. + *******************************************************************************/ internal data class LocationFix(val latitude: Double, val longitude: Double, val accuracyMeters: Double, val capturedAt: String) -/** Default-on foreground-only telemetry. Denying permission never prevents scanning. */ -internal class ForegroundLocationState -{ +/******************************************************************************* + ** Default-on foreground-only telemetry. Denying permission never prevents scanning. + *******************************************************************************/ +internal class ForegroundLocationState { var report by mutableStateOf(LocationReport()) var permissionRevision by mutableStateOf(0) var enabled by mutableStateOf(true) @@ -49,19 +57,21 @@ internal class ForegroundLocationState var setEnabled: (Boolean) -> Unit = {} } +/******************************************************************************* + ** Attach permission and provider listeners to the foreground host and remove updates on disposal. + *******************************************************************************/ @Composable -internal fun rememberForegroundLocation(active: Boolean): ForegroundLocationState -{ +internal fun rememberForegroundLocation(active: Boolean): ForegroundLocationState { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val preferences = remember(context) { context.getSharedPreferences("host", Context.MODE_PRIVATE) } val state = remember { ForegroundLocationState().apply { - enabled = LocationPolicy.enabled(if(preferences.contains("locationReportingEnabled")) preferences.getBoolean("locationReportingEnabled", true) else null) + enabled = LocationPolicy.enabled(if (preferences.contains("locationReportingEnabled")) preferences.getBoolean("locationReportingEnabled", true) else null) setEnabled = { value -> preferences.edit().putBoolean("locationReportingEnabled", value).apply() enabled = value - if(!value) report = LocationReport("DISABLED") + if (!value) report = LocationReport("DISABLED") } } } @@ -70,10 +80,14 @@ internal fun rememberForegroundLocation(active: Boolean): ForegroundLocationStat { state.permissionRevision++ } DisposableEffect(lifecycleOwner, context) { - val observer = LifecycleEventObserver { _, event -> if(event == Lifecycle.Event.ON_RESUME) state.permissionRevision++ } - val receiver = object : BroadcastReceiver() - { - override fun onReceive(context: Context?, intent: Intent?) { state.permissionRevision++ } + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) state.permissionRevision++ } + val receiver = object : BroadcastReceiver() { + /******************************************************************************* + ** Reevaluate provider and permission state after Android changes location availability. + *******************************************************************************/ + override fun onReceive(context: Context?, intent: Intent?) { + state.permissionRevision++ + } } lifecycleOwner.lifecycle.addObserver(observer) ContextCompat.registerReceiver(context, receiver, IntentFilter().apply { @@ -86,8 +100,7 @@ internal fun rememberForegroundLocation(active: Boolean): ForegroundLocationStat val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED LaunchedEffect(active, state.enabled, permitted, permissionRevision) { - if(LocationPolicy.shouldRequestPermission(active, state.enabled, permitted, preferences.getBoolean("locationPermissionRequested", false))) - { + if (LocationPolicy.shouldRequestPermission(active, state.enabled, permitted, preferences.getBoolean("locationPermissionRequested", false))) { // Record before launching: recreation, resume and denial must not prompt again. preferences.edit().putBoolean("locationPermissionRequested", true).apply() launcher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) @@ -101,30 +114,55 @@ internal fun rememberForegroundLocation(active: Boolean): ForegroundLocationStat val providers = manager.getProviders(true).filter { it == LocationManager.GPS_PROVIDER || it == LocationManager.NETWORK_PROVIDER } state.providersEnabled = providers.isNotEmpty() var listening = true - val listener = object : LocationListener - { - override fun onLocationChanged(location: Location) - { - if(!listening || !active || !state.enabled || !location.hasAccuracy()) return - state.report = LocationReport("AVAILABLE", LocationFix(location.latitude, location.longitude, - location.accuracy.toDouble(), Instant.ofEpochMilli(location.time).toString())) + val listener = object : LocationListener { + /******************************************************************************* + ** Publish a fix only while the current foreground subscription remains eligible and accurate. + *******************************************************************************/ + override fun onLocationChanged(location: Location) { + if (!listening || !active || !state.enabled || !location.hasAccuracy()) return + state.report = LocationReport( + "AVAILABLE", LocationFix( + location.latitude, location.longitude, + location.accuracy.toDouble(), Instant.ofEpochMilli(location.time).toString() + ) + ) } - override fun onProviderEnabled(provider: String) { state.permissionRevision++ } - override fun onProviderDisabled(provider: String) { state.permissionRevision++ } - @Deprecated("Legacy callback") override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit + + /******************************************************************************* + ** Refresh provider eligibility instead of assuming a newly enabled provider already has a fix. + *******************************************************************************/ + override fun onProviderEnabled(provider: String) { + state.permissionRevision++ + } + + /******************************************************************************* + ** Refresh availability so a disabled provider cannot leave the previous status displayed. + *******************************************************************************/ + override fun onProviderDisabled(provider: String) { + state.permissionRevision++ + } + + /******************************************************************************* + ** Retain the legacy Android callback without treating its obsolete status as a location fix. + *******************************************************************************/ + @Deprecated("Legacy callback") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit } - state.report = LocationReport(when { - !state.enabled -> "DISABLED" - !permitted -> "PERMISSION_DENIED" - !state.providersEnabled -> "DISABLED" - else -> "UNAVAILABLE" - }) - if(active && state.enabled && permitted) - { - for(provider in providers.filter { state.precise || it != LocationManager.GPS_PROVIDER }) - { - try { manager.requestLocationUpdates(provider, 10000L, 10f, listener) } - catch(exception: SecurityException) { state.report = LocationReport("PERMISSION_DENIED"); state.permitted = false } + state.report = LocationReport( + when { + !state.enabled -> "DISABLED" + !permitted -> "PERMISSION_DENIED" + !state.providersEnabled -> "DISABLED" + else -> "UNAVAILABLE" + } + ) + if (active && state.enabled && permitted) { + for (provider in providers.filter { state.precise || it != LocationManager.GPS_PROVIDER }) { + try { + manager.requestLocationUpdates(provider, 10000L, 10f, listener) + } catch (exception: SecurityException) { + state.report = LocationReport("PERMISSION_DENIED"); state.permitted = false + } } } onDispose { listening = false; manager.removeUpdates(listener); state.report = LocationReport() } @@ -132,24 +170,23 @@ internal fun rememberForegroundLocation(active: Boolean): ForegroundLocationStat return state } +/******************************************************************************* + ** Expose independent reporting and Android permission controls without blocking barcode scanning. + *******************************************************************************/ @Composable -internal fun LocationPermissionControl(state: ForegroundLocationState) -{ +internal fun LocationPermissionControl(state: ForegroundLocationState) { val context = LocalContext.current Row { Text("Report foreground location") Switch(checked = state.enabled, onCheckedChange = state.setEnabled) } Text(LocationPolicy.status(state.enabled, state.permitted, state.providersEnabled, state.active, state.report.status)) - if(state.permitted) Text(if(state.precise) "Android permission: precise location" else "Android permission: approximate location") + if (state.permitted) Text(if (state.precise) "Android permission: precise location" else "Android permission: approximate location") Text("Default on. Reporting requires Android permission and an approved signed-in foreground session. Scanning works without location.") - if(state.enabled && !state.permitted) - { + if (state.enabled && !state.permitted) { TextButton(onClick = { context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))) }) { Text("Open location permission settings") } - } - else if(state.enabled && !state.providersEnabled) - { + } else if (state.enabled && !state.providersEnabled) { TextButton(onClick = { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }) { Text("Open device location settings") } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt index d24d404..f9d06db 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt @@ -8,15 +8,36 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed import kotlinx.coroutines.CancellationException import android.net.ConnectivityManager import android.net.Network -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -30,10 +51,11 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.isActive import kotlinx.coroutines.withTimeoutOrNull -/** Lifetime is keyed by selected server: presentation and diagnostics cannot bleed across customers. */ +/******************************************************************************* + ** Lifetime is keyed by selected server: presentation and diagnostics cannot bleed across customers. + *******************************************************************************/ @Composable -internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Composable (HostBrand, DiagnosticLog, UiApiCompatibility, () -> Unit) -> Unit) -{ +internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Composable (HostBrand, DiagnosticLog, UiApiCompatibility, () -> Unit) -> Unit) { val runtime = remember(server) { HostRuntime(server) } var compatible by remember(server) { mutableStateOf(false) } var compatibilityError by remember(server) { mutableStateOf(null) } @@ -51,12 +73,13 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co LaunchedEffect(runtime, attempt) { compatibilityError = null - try { runtime.verifyCompatibility() } - catch(exception: CancellationException) { throw exception } - catch(exception: Exception) - { - compatibilityError = if(exception is UiApiCompatibilityException) exception.message else "Unable to check QQQ UI API compatibility. Check the server address and connection, then retry." - connection = if(exception is UiApiCompatibilityException) "Incompatible server" else "Disconnected" + try { + runtime.verifyCompatibility() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + compatibilityError = if (exception is UiApiCompatibilityException) exception.message else "Unable to check QQQ UI API compatibility. Check the server address and connection, then retry." + connection = if (exception is UiApiCompatibilityException) "Incompatible server" else "Disconnected" runtime.diagnostics.record(DiagnosticCode.COMPATIBILITY_FAILED, null, 0) return@LaunchedEffect } @@ -66,34 +89,47 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co } LaunchedEffect(runtime, lifecycle, compatible) { - if(!compatible) return@LaunchedEffect + if (!compatible) return@LaunchedEffect lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { val changed = Channel(Channel.CONFLATED) val connectivity = context.getSystemService(ConnectivityManager::class.java) - val callback = object : ConnectivityManager.NetworkCallback() - { - override fun onAvailable(network: Network) { changed.trySend(Unit) } - override fun onLost(network: Network) { changed.trySend(Unit) } + val callback = object : ConnectivityManager.NetworkCallback() { + /******************************************************************************* + ** Schedule a fresh explicit reachability check when Android reports an available network. + *******************************************************************************/ + override fun onAvailable(network: Network) { + changed.trySend(Unit) + } + + /******************************************************************************* + ** Recheck reachability after network loss instead of retaining an old connection indication. + *******************************************************************************/ + override fun onLost(network: Network) { + changed.trySend(Unit) + } } connectivity.registerDefaultNetworkCallback(callback) - try - { + try { connection = "Checking" - while(isActive) - { - connection = if(runtime.connected()) "Connected" else "Disconnected" + while (isActive) { + connection = if (runtime.connected()) "Connected" else "Disconnected" checkedAt = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) withTimeoutOrNull(5000) { changed.receive() } } + } finally { + connectivity.unregisterNetworkCallback(callback); changed.close() } - finally { connectivity.unregisterNetworkCallback(callback); changed.close() } } } val primary = Color(0xFF000000 or brand.primaryColor.drop(1).toLong(16)) - val onPrimary = if(useBlackOnPrimary(brand.primaryColor)) Color.Black else Color.White - MaterialTheme(colorScheme = lightColorScheme(primary = primary, onPrimary = onPrimary, - primaryContainer = lerp(primary, Color.White, 0.85f), onPrimaryContainer = Color.Black)) + val onPrimary = if (useBlackOnPrimary(brand.primaryColor)) Color.Black else Color.White + MaterialTheme( + colorScheme = lightColorScheme( + primary = primary, onPrimary = onPrimary, + primaryContainer = lerp(primary, Color.White, 0.85f), onPrimaryContainer = Color.Black + ) + ) { Surface() { @@ -103,18 +139,18 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co { logo?.let { AsyncImage(model = it, imageLoader = imageLoader, contentDescription = "${brand.displayName} logo", modifier = Modifier.size(40.dp)) } Text(brand.displayName, Modifier.weight(1f), style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) - if(!compatible) TextButton(onClick = onChangeServer) { Text("Change server") } + if (!compatible) TextButton(onClick = onChangeServer) { Text("Change server") } } Box(Modifier.weight(1f)) { - if(compatible) CompositionLocalProvider( + if (compatible) CompositionLocalProvider( LocalOpenDiagnostics provides { showDiagnostics = true }, LocalProcessInputAllowed provides { parentInputAllowed() && !showDiagnostics }) { content(brand, runtime.diagnostics, runtime.compatibility, onChangeServer) } else Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(compatibilityError ?: "Checking QQQ UI API compatibility…") - if(compatibilityError != null) Button(onClick = { attempt++ }) { Text("Retry compatibility check") } + if (compatibilityError != null) Button(onClick = { attempt++ }) { Text("Retry compatibility check") } } } HorizontalDivider() @@ -124,6 +160,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co } } } - if(showDiagnostics) DiagnosticScreen(runtime.diagnostics, onBack = { showDiagnostics = false }) + if (showDiagnostics) DiagnosticScreen(runtime.diagnostics, onBack = { showDiagnostics = false }) } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt index c985a55..197d3ad 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt @@ -19,14 +19,23 @@ import okhttp3.OkHttpClient import okhttp3.Request import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome -/** Public presentation data; never mixed with a session or device credential. */ -internal data class HostBrand(val displayName: String = "QQQ Mobile Scanners", val primaryColor: String = "#1565C0", val logoUrl: String? = null) -{ - companion object - { - fun parse(payload: String): HostBrand - { +/******************************************************************************* + ** Public presentation data; never mixed with a session or device credential. + *******************************************************************************/ +internal data class HostBrand(val displayName: String = "QQQ Mobile Scanners", val primaryColor: String = "#1565C0", val logoUrl: String? = null) { + /******************************************************************************* + ** Centralize public branding validation so every host applies the same safe presentation defaults. + *******************************************************************************/ + companion object { + /******************************************************************************* + ** Accept bounded presentation fields independently so malformed branding safely falls back to local defaults. + *******************************************************************************/ + fun parse(payload: String): HostBrand { val fields = runCatching { Json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: return HostBrand() + + /******************************************************************************* + ** Read only JSON strings; coercing other values would bypass the presentation-field validation. + *******************************************************************************/ fun string(name: String): String? = (fields[name] as? JsonPrimitive)?.takeIf { it.isString }?.content val name = string("displayName")?.trim()?.takeIf { it.length in 1..80 && it.none { c -> c.isISOControl() || c == '<' || c == '>' } } val color = string("primaryColor")?.takeIf { Regex("#[0-9a-fA-F]{6}").matches(it) }?.uppercase() @@ -36,87 +45,147 @@ internal data class HostBrand(val displayName: String = "QQQ Mobile Scanners", v } } +/******************************************************************************* + ** Allow only credential-free HTTPS image URLs with a host and no fragment. + *******************************************************************************/ internal fun isSafeLogoUrl(value: String): Boolean = runCatching { val uri = URI(value) value.length <= 2048 && uri.scheme == "https" && uri.host != null && uri.userInfo == null && uri.fragment == null }.getOrDefault(false) -/** Choose the higher WCAG contrast of black/white against the supplied opaque color. */ -internal fun useBlackOnPrimary(hex: String): Boolean -{ +/******************************************************************************* + ** Choose the higher WCAG contrast of black/white against the supplied opaque color. + *******************************************************************************/ +internal fun useBlackOnPrimary(hex: String): Boolean { val channels = listOf(1, 3, 5).map { hex.substring(it, it + 2).toInt(16) / 255.0 } - .map { if(it <= 0.04045) it / 12.92 else ((it + 0.055) / 1.055).pow(2.4) } + .map { if (it <= 0.04045) it / 12.92 else ((it + 0.055) / 1.055).pow(2.4) } val luminance = channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722 return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) } +/******************************************************************************* + ** Restrict diagnostic categories so server bodies and credentials cannot become event messages. + *******************************************************************************/ internal enum class DiagnosticCode { COMPATIBILITY_RESPONSE, COMPATIBILITY_FAILED, BRAND_LOADED, BRAND_DEFAULT, LOGO_LOADED, LOGO_UNAVAILABLE, CONNECTION_OK, CONNECTION_FAILED, NATIVE_RESPONSE, NATIVE_TRANSPORT_FAILED, DEVICE_RESPONSE, DEVICE_TRANSPORT_FAILED, SESSION_RESPONSE, SESSION_TRANSPORT_FAILED } + +/******************************************************************************* + ** Retain only a timestamp, fixed category, status and duration for operator diagnostics. + *******************************************************************************/ internal data class DiagnosticEvent(val time: Instant, val code: DiagnosticCode, val httpStatus: Int?, val elapsedMillis: Long) -/** No free-form messages: raw responses, URLs, credentials, and barcode data cannot enter this log. */ -internal class DiagnosticLog(private val capacity: Int = 100) -{ +/******************************************************************************* + ** No free-form messages: raw responses, URLs, credentials, and barcode data cannot enter this log. + *******************************************************************************/ +internal class DiagnosticLog(private val capacity: Int = 100) { private val events = ArrayDeque() - @Synchronized fun record(code: DiagnosticCode, status: Int?, elapsedMillis: Long) - { - events.addLast(DiagnosticEvent(Instant.now(), code, status, elapsedMillis.coerceAtLeast(0))) - while(events.size > capacity) events.removeFirst() - } - @Synchronized fun text(): String = events.joinToString("\n") { "${it.time} ${it.code} HTTP=${it.httpStatus ?: "none"} ${it.elapsedMillis}ms" } - @Synchronized fun snapshot(): List = events.toList() - @Synchronized fun clear() { events.clear() } - fun nativeRequest(outcome: RequestOutcome) = record(if(outcome.httpStatus == null) DiagnosticCode.NATIVE_TRANSPORT_FAILED else DiagnosticCode.NATIVE_RESPONSE, outcome.httpStatus, outcome.elapsedMillis) - fun deviceRequest(outcome: RequestOutcome) = record(if(outcome.httpStatus == null) DiagnosticCode.DEVICE_TRANSPORT_FAILED else DiagnosticCode.DEVICE_RESPONSE, outcome.httpStatus, outcome.elapsedMillis) - fun sessionRequest(outcome: RequestOutcome) = record(if(outcome.httpStatus == null) DiagnosticCode.SESSION_TRANSPORT_FAILED else DiagnosticCode.SESSION_RESPONSE, outcome.httpStatus, outcome.elapsedMillis) + /******************************************************************************* + ** Bound retained activity and synchronize writers so diagnostics cannot grow with session duration. + *******************************************************************************/ + @Synchronized + fun record(code: DiagnosticCode, status: Int?, elapsedMillis: Long) { + events.addLast(DiagnosticEvent(Instant.now(), code, status, elapsedMillis.coerceAtLeast(0))) + while (events.size > capacity) events.removeFirst() + } + + /******************************************************************************* + ** Export only fixed event fields; never include request URLs, response bodies or credentials. + *******************************************************************************/ + @Synchronized + fun text(): String = events.joinToString("\n") { "${it.time} ${it.code} HTTP=${it.httpStatus ?: "none"} ${it.elapsedMillis}ms" } + + /******************************************************************************* + ** Return an independent ordered snapshot so callers cannot mutate the bounded diagnostic queue. + *******************************************************************************/ + @Synchronized + fun snapshot(): List = events.toList() + + /******************************************************************************* + ** Clear retained events under the same lock used by concurrent request observers. + *******************************************************************************/ + @Synchronized + fun clear() { + events.clear() + } + + /******************************************************************************* + ** Classify native API outcomes without retaining the request or any authentication material. + *******************************************************************************/ + fun nativeRequest(outcome: RequestOutcome) = record(if (outcome.httpStatus == null) DiagnosticCode.NATIVE_TRANSPORT_FAILED else DiagnosticCode.NATIVE_RESPONSE, outcome.httpStatus, outcome.elapsedMillis) + + /******************************************************************************* + ** Classify enrollment outcomes using only transport status and elapsed time. + *******************************************************************************/ + fun deviceRequest(outcome: RequestOutcome) = record(if (outcome.httpStatus == null) DiagnosticCode.DEVICE_TRANSPORT_FAILED else DiagnosticCode.DEVICE_RESPONSE, outcome.httpStatus, outcome.elapsedMillis) + + /******************************************************************************* + ** Classify session telemetry outcomes without retaining transmitted metadata or pixels. + *******************************************************************************/ + fun sessionRequest(outcome: RequestOutcome) = record(if (outcome.httpStatus == null) DiagnosticCode.SESSION_TRANSPORT_FAILED else DiagnosticCode.SESSION_RESPONSE, outcome.httpStatus, outcome.elapsedMillis) } -/** Dedicated unauthenticated client with no cookie jar, redirects, or disk cache. */ -internal class HostRuntime(private val server: String, val diagnostics: DiagnosticLog = DiagnosticLog()) -{ +/******************************************************************************* + ** Dedicated unauthenticated client with no cookie jar, redirects, or disk cache. + *******************************************************************************/ +internal class HostRuntime(private val server: String, val diagnostics: DiagnosticLog = DiagnosticLog()) { val compatibility = UiApiCompatibility(server, requireMobileScanners = true) { outcome -> - diagnostics.record(if(outcome.httpStatus == 200) DiagnosticCode.COMPATIBILITY_RESPONSE else DiagnosticCode.COMPATIBILITY_FAILED, outcome.httpStatus, outcome.elapsedMillis) + diagnostics.record(if (outcome.httpStatus == 200) DiagnosticCode.COMPATIBILITY_RESPONSE else DiagnosticCode.COMPATIBILITY_FAILED, outcome.httpStatus, outcome.elapsedMillis) } + /******************************************************************************* + ** Discover required server capabilities before any host operation can send credentials. + *******************************************************************************/ suspend fun verifyCompatibility() = withContext(Dispatchers.IO) { compatibility.verify() } private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) .callTimeout(5, TimeUnit.SECONDS).build() + /******************************************************************************* + ** Bound presentation responses and record safe outcomes while preserving coroutine cancellation. + *******************************************************************************/ private suspend fun fetch(url: String, limit: Long, success: DiagnosticCode, failure: DiagnosticCode, validate: (ByteArray) -> Boolean = { true }): ByteArray? = withContext(Dispatchers.IO) { val start = System.nanoTime() var status: Int? = null - try - { - if(url.startsWith(server.trimEnd('/') + MOBILE_SCANNERS_PATH)) compatibility.verify() - client.newCall(Request.Builder().url(url).header("Cache-Control", "no-store") - .apply { if(url.startsWith(server.trimEnd('/') + MOBILE_SCANNERS_PATH)) header("X-QQQ-Client", ANDROID_CLIENT) }.build()).execute().use { response -> + try { + if (url.startsWith(server.trimEnd('/') + MOBILE_SCANNERS_PATH)) compatibility.verify() + client.newCall( + Request.Builder().url(url).header("Cache-Control", "no-store") + .apply { if (url.startsWith(server.trimEnd('/') + MOBILE_SCANNERS_PATH)) header("X-QQQ-Client", ANDROID_CLIENT) }.build() + ).execute().use { response -> status = response.code val body = response.body - if(!response.isSuccessful || body == null || body.contentLength() > limit) error("Unavailable") + if (!response.isSuccessful || body == null || body.contentLength() > limit) error("Unavailable") val source = body.source() source.request(limit + 1) - if(source.buffer.size > limit) error("Oversized") + if (source.buffer.size > limit) error("Oversized") val bytes = source.readByteArray() - if(!validate(bytes)) error("Unexpected response") + if (!validate(bytes)) error("Unexpected response") diagnostics.record(success, status, (System.nanoTime() - start) / 1_000_000) bytes } - } - catch(exception: CancellationException) { throw exception } - catch(exception: Exception) - { + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { diagnostics.record(failure, status, (System.nanoTime() - start) / 1_000_000) null } } + /******************************************************************************* + ** Fetch public branding with local defaults when transport or field validation fails. + *******************************************************************************/ suspend fun branding(): HostBrand = fetch(server.trimEnd('/') + MOBILE_SCANNERS_PATH + "/branding", 8192, DiagnosticCode.BRAND_LOADED, DiagnosticCode.BRAND_DEFAULT) ?.toString(Charsets.UTF_8)?.let { HostBrand.parse(it) } ?: HostBrand() - suspend fun logo(url: String): ByteArray? = if(isSafeLogoUrl(url)) fetch(url, 1_048_576, DiagnosticCode.LOGO_LOADED, DiagnosticCode.LOGO_UNAVAILABLE) else null + /******************************************************************************* + ** Apply the public HTTPS URL policy and byte limit before decoding remote artwork. + *******************************************************************************/ + suspend fun logo(url: String): ByteArray? = if (isSafeLogoUrl(url)) fetch(url, 1_048_576, DiagnosticCode.LOGO_LOADED, DiagnosticCode.LOGO_UNAVAILABLE) else null + /******************************************************************************* + ** Require the extension's explicit reachable response instead of treating any HTTP response as success. + *******************************************************************************/ suspend fun connected(): Boolean = fetch(server.trimEnd('/') + MOBILE_SCANNERS_PATH + "/status", 1024, DiagnosticCode.CONNECTION_OK, DiagnosticCode.CONNECTION_FAILED) { val fields = Json.parseToJsonElement(it.toString(Charsets.UTF_8)) as? JsonObject (fields?.get("status") as? JsonPrimitive)?.let { status -> status.isString && status.content == "ok" } == true diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/LatestScreenImage.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/LatestScreenImage.kt index 8d13f91..f5a23e9 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/LatestScreenImage.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/LatestScreenImage.kt @@ -1,19 +1,27 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package io.qrun.mobile.scanner -/** Main-thread owner of one captured image. Replacing or clearing releases the old buffer. */ -internal class LatestScreenImage : AutoCloseable -{ +/******************************************************************************* + ** Main-thread owner of one captured image. Replacing or clearing releases the old buffer. + *******************************************************************************/ +internal class LatestScreenImage : AutoCloseable { var image: T? = null private set - fun replace(next: T?) - { - if(image === next) return + /******************************************************************************* + ** Adopt the replacement and close the previous image, keeping retained image ownership bounded. + *******************************************************************************/ + fun replace(next: T?) { + if (image === next) return val previous = image image = next previous?.close() } - override fun close() { replace(null) } + /******************************************************************************* + ** Release the retained image through the same replacement path used for incoming frames. + *******************************************************************************/ + override fun close() { + replace(null) + } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/LocationPolicy.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/LocationPolicy.kt index dc91b4d..67bed53 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/LocationPolicy.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/LocationPolicy.kt @@ -1,16 +1,25 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package io.qrun.mobile.scanner -/** The reporting preference and Android permission are independent controls. */ -internal object LocationPolicy -{ +/******************************************************************************* + ** The reporting preference and Android permission are independent controls. + *******************************************************************************/ +internal object LocationPolicy { + /******************************************************************************* + ** Use the product default only when no explicit operator reporting preference exists. + *******************************************************************************/ fun enabled(saved: Boolean?) = saved ?: true + /******************************************************************************* + ** Request Android permission once for an eligible host; denial must not trigger repeated prompts. + *******************************************************************************/ fun shouldRequestPermission(active: Boolean, enabled: Boolean, permitted: Boolean, previouslyRequested: Boolean) = active && enabled && !permitted && !previouslyRequested - fun status(enabled: Boolean, permitted: Boolean, providersEnabled: Boolean, active: Boolean, report: String): String = when - { + /******************************************************************************* + ** Distinguish reporting preference, permission, provider state and foreground eligibility for the operator. + *******************************************************************************/ + fun status(enabled: Boolean, permitted: Boolean, providersEnabled: Boolean, active: Boolean, report: String): String = when { !enabled -> "Location reporting is off" !permitted -> "Location reporting is on; Android location permission is not granted" !providersEnabled -> "Location reporting is on; device location providers are off" diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt index e2d0b85..31fda6d 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt @@ -23,56 +23,83 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.json.JSONObject -/** Session and screen traffic uses the registered extension and the existing QQQ/device identity. */ -internal interface MobileSessionTransport -{ +/******************************************************************************* + ** Session and screen traffic uses the registered extension and the existing QQQ/device identity. + *******************************************************************************/ +internal interface MobileSessionTransport { + /******************************************************************************* + ** Send the current eligible session metadata and accept only the server's bounded lease policy. + *******************************************************************************/ suspend fun heartbeat(session: String, state: SessionScreen): SessionHeartbeat + + /******************************************************************************* + ** End only the captured authenticated session with an explicit sign-out or server-change reason. + *******************************************************************************/ suspend fun end(session: String, reason: String) + + /******************************************************************************* + ** Upload a frame under its exact server lease and sequence; pixels never become process values. + *******************************************************************************/ suspend fun screen(session: String, leaseId: String, sequence: Long, frame: ScreenFrame) } +/******************************************************************************* + ** Bind extension traffic to one server and its existing session/device headers without following redirects. + *******************************************************************************/ internal class MobileSessionApi( private val server: String, private val headers: () -> Map, private val compatibility: UiApiCompatibility, onOutcome: (RequestOutcome) -> Unit = {}, -) : MobileSessionTransport -{ +) : MobileSessionTransport { private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) .callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).build() - override suspend fun heartbeat(session: String, state: SessionScreen): SessionHeartbeat - { + /******************************************************************************* + ** Send the current eligible session metadata and accept only the server's bounded lease policy. + *******************************************************************************/ + override suspend fun heartbeat(session: String, state: SessionScreen): SessionHeartbeat { val locationAvailable = state.foreground && state.location.status == "AVAILABLE" && state.location.fix != null - val locationStatus = if(!state.foreground || (state.location.status == "AVAILABLE" && !locationAvailable)) "UNAVAILABLE" else state.location.status + val locationStatus = if (!state.foreground || (state.location.status == "AVAILABLE" && !locationAvailable)) "UNAVAILABLE" else state.location.status val json = JSONObject().put("foreground", state.foreground).put("screenAvailable", state.available) .put("locationStatus", locationStatus) - if(state.captureMode != CaptureMode.APP_CONTENT) json.put("captureMode", state.captureMode.name) + if (state.captureMode != CaptureMode.APP_CONTENT) json.put("captureMode", state.captureMode.name) state.location.fix?.takeIf { locationAvailable }?.let { fix -> - json.put("location", JSONObject().put("latitude", fix.latitude).put("longitude", fix.longitude) - .put("accuracyMeters", fix.accuracyMeters).put("capturedAt", fix.capturedAt)) + json.put( + "location", JSONObject().put("latitude", fix.latitude).put("longitude", fix.longitude) + .put("accuracyMeters", fix.accuracyMeters).put("capturedAt", fix.capturedAt) + ) } - mapOf("appName" to state.appName, "processName" to state.processName, "processInstanceId" to state.processInstanceId, - "stepName" to state.stepName, "screenLabel" to state.label, "captureBlockedReason" to state.blockedReason) + mapOf( + "appName" to state.appName, "processName" to state.processName, "processInstanceId" to state.processInstanceId, + "stepName" to state.stepName, "screenLabel" to state.label, "captureBlockedReason" to state.blockedReason + ) .forEach { (name, value) -> json.put(name, value ?: JSONObject.NULL) } return SessionHeartbeat.parse(post(session, "/sessions/heartbeat", json, 200)) } - override suspend fun end(session: String, reason: String) - { + /******************************************************************************* + ** End only the captured authenticated session with an explicit sign-out or server-change reason. + *******************************************************************************/ + override suspend fun end(session: String, reason: String) { require(reason == "SIGN_OUT" || reason == "SERVER_CHANGE") post(session, "/sessions/current/end", JSONObject().put("reason", reason), 200) } - override suspend fun screen(session: String, leaseId: String, sequence: Long, frame: ScreenFrame) - { + /******************************************************************************* + ** Upload a frame under its exact server lease and sequence; pixels never become process values. + *******************************************************************************/ + override suspend fun screen(session: String, leaseId: String, sequence: Long, frame: ScreenFrame) { val json = JSONObject().put("leaseId", leaseId).put("sequence", sequence).put("capturedAt", frame.capturedAt) .put("width", frame.width).put("height", frame.height).put("mimeType", "image/jpeg") .put("imageBase64", java.util.Base64.getEncoder().encodeToString(frame.jpeg)) - if(frame.captureMode != CaptureMode.APP_CONTENT) json.put("captureMode", frame.captureMode.name) + if (frame.captureMode != CaptureMode.APP_CONTENT) json.put("captureMode", frame.captureMode.name) post(session, "/sessions/current/screen", json, 204) } + /******************************************************************************* + ** Verify compatibility and close bounded responses while connecting coroutine cancellation to the HTTP call. + *******************************************************************************/ private suspend fun post(session: String, path: String, body: JSONObject, expected: Int): String = withContext(Dispatchers.IO) { require(session.isNotBlank()) @@ -86,20 +113,26 @@ internal class MobileSessionApi( val call = client.newCall(request.build()) val response = suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { call.cancel() } - call.enqueue(object : Callback - { - override fun onFailure(call: Call, e: IOException) { if(continuation.isActive) continuation.resumeWithException(e) } - override fun onResponse(call: Call, response: Response) - { + call.enqueue(object : Callback { + /******************************************************************************* + ** Resume only an active continuation so cancellation cannot revive the abandoned HTTP operation. + *******************************************************************************/ + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) continuation.resumeWithException(e) + } + + /******************************************************************************* + ** Transfer response ownership to the continuation and close it if cancellation wins the race. + *******************************************************************************/ + override fun onResponse(call: Call, response: Response) { continuation.resume(response) { _, value, _ -> value.close() } } }) } response.use { - if(!it.isSuccessful) throw UiApiException.from(it) + if (!it.isSuccessful) throw UiApiException.from(it) check(it.code == expected) { "Unexpected mobile session response." } - if(expected == 204) "" else - { + if (expected == 204) "" else { val source = it.body?.source() ?: error("Empty mobile session response.") source.request(16385) check(source.buffer.size <= 16384) { "Oversized mobile session response." } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionController.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionController.kt index 28daeed..818ccba 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionController.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionController.kt @@ -17,7 +17,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -/** One authenticated session. No screenshots or telemetry survive this controller's lifetime. */ +/******************************************************************************* + ** One authenticated session. No screenshots or telemetry survive this controller's lifetime. + *******************************************************************************/ internal class MobileSessionController( private val session: String, private val api: MobileSessionTransport, @@ -27,8 +29,7 @@ internal class MobileSessionController( private val now: () -> Long = android.os.SystemClock::elapsedRealtime, private val onDeviceViewingStopped: () -> Unit = {}, private val onDeviceLease: (Long) -> Unit = {}, -) -{ +) { var operatorPaused by mutableStateOf(false) private set var viewing by mutableStateOf(false) @@ -42,32 +43,58 @@ internal class MobileSessionController( private var leaseExpiresAt = 0L private var sequence = 0L + /******************************************************************************* + ** Overlay the live operator pause on the current host state before lease and upload decisions. + *******************************************************************************/ private fun screen() = readScreen().copy(paused = operatorPaused) - fun pauseViewing() { operatorPaused = true; generation++; viewing = false; onDeviceViewingStopped() } - fun allowViewing() { operatorPaused = false; generation++ } + /******************************************************************************* + ** Invalidate in-flight capture immediately and stop device projection when the operator pauses. + *******************************************************************************/ + fun pauseViewing() { + operatorPaused = true; generation++; viewing = false; onDeviceViewingStopped() + } - /** Invalidating synchronously prevents a completed PixelCopy callback from uploading after exit. */ - fun invalidate() { ended = true; generation++; lease = null; viewing = false; onDeviceViewingStopped(); running?.cancel() } + /******************************************************************************* + ** Invalidate prior capture tickets when viewing is allowed again; a fresh lease check still governs upload. + *******************************************************************************/ + fun allowViewing() { + operatorPaused = false; generation++ + } - suspend fun end(reason: String) - { - if(ended) return + /******************************************************************************* + ** Invalidating synchronously prevents a completed PixelCopy callback from uploading after exit. + *******************************************************************************/ + fun invalidate() { + ended = true; generation++; lease = null; viewing = false; onDeviceViewingStopped(); running?.cancel() + } + + /******************************************************************************* + ** Cancel the heartbeat owner before ending the captured server session; ordinary logout can continue on failure. + *******************************************************************************/ + suspend fun end(reason: String) { + if (ended) return ended = true generation++ lease = null viewing = false onDeviceViewingStopped() val job = running - if(job != currentCoroutineContext()[Job]) job?.cancelAndJoin() - if(session.isNotBlank()) try { api.end(session, reason) } - catch(exception: CancellationException) { throw exception } - catch(exception: Exception) { /* Standard logout still runs and always clears this device. */ } + if (job != currentCoroutineContext()[Job]) job?.cancelAndJoin() + if (session.isNotBlank()) try { + api.end(session, reason) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { /* Standard logout still runs and always clears this device. */ + } } + /******************************************************************************* + ** Own heartbeats and leased frame uploads for one authenticated lifetime, rejecting changed-state capture tickets. + *******************************************************************************/ suspend fun run() = coroutineScope() { - if(session.isBlank() || ended) return@coroutineScope + if (session.isBlank() || ended) return@coroutineScope running = currentCoroutineContext()[Job] val changed = Channel(Channel.CONFLATED) val observer = launch { snapshotFlow { screen() }.collect { changed.trySend(Unit) } } @@ -77,15 +104,11 @@ internal class MobileSessionController( var heartbeatInterval = 5000L var deviceStartedAt = 0L var deviceHadLease = false - try - { - while(isActive && !ended) - { + try { + while (isActive && !ended) { val state = screen() - if(state != previous) - { - if(state.captureMode != previous?.captureMode) - { + if (state != previous) { + if (state.captureMode != previous?.captureMode) { lease = null deviceStartedAt = now() deviceHadLease = false @@ -93,78 +116,82 @@ internal class MobileSessionController( generation++ previous = state nextHeartbeat = 0L - if(!state.available) { lease = null; viewing = false } + if (!state.available) { + lease = null; viewing = false + } } - if(!state.authenticated || !state.approved) break - if(now() >= nextHeartbeat) - { + if (!state.authenticated || !state.approved) break + if (now() >= nextHeartbeat) { val started = now() - try - { + try { val response = api.heartbeat(session, state) heartbeatInterval = response.intervalMillis supportsDeviceScreen = CaptureMode.DEVICE_SCREEN in response.supportedCaptureModes // A slow response cannot extend the lease beyond its server-derived remaining lifetime. val newLease = response.lease?.takeIf { it.captureMode == state.captureMode } leaseExpiresAt = started + (newLease?.durationMillis ?: 0) - if(newLease?.id != lease?.id) sequence = newLease?.nextSequence ?: 0L + if (newLease?.id != lease?.id) sequence = newLease?.nextSequence ?: 0L else sequence = maxOf(sequence, newLease?.nextSequence ?: 0L) lease = newLease - } - catch(exception: CancellationException) { throw exception } - catch(exception: Exception) - { + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { lease = null - if(state.captureMode == CaptureMode.DEVICE_SCREEN) pauseViewing() - if(exception is UiApiException && exception.httpStatus in setOf(401, 403)) { onUnauthorized(); break } + if (state.captureMode == CaptureMode.DEVICE_SCREEN) pauseViewing() + if (exception is UiApiException && exception.httpStatus in setOf(401, 403)) { + onUnauthorized(); break + } } - nextHeartbeat = if(state.foreground || state.captureMode == CaptureMode.DEVICE_SCREEN) now() + heartbeatInterval else Long.MAX_VALUE + nextHeartbeat = if (state.foreground || state.captureMode == CaptureMode.DEVICE_SCREEN) now() + heartbeatInterval else Long.MAX_VALUE } val activeLease = lease - if(screen() != state) continue + if (screen() != state) continue viewing = activeLease != null && state.available && now() < leaseExpiresAt - if(state.captureMode == CaptureMode.DEVICE_SCREEN) - { - if(viewing) { deviceHadLease = true; onDeviceLease(leaseExpiresAt) } - if(!state.available || !supportsDeviceScreen || (!viewing && (deviceHadLease || now() - deviceStartedAt >= 30000))) - { + if (state.captureMode == CaptureMode.DEVICE_SCREEN) { + if (viewing) { + deviceHadLease = true; onDeviceLease(leaseExpiresAt) + } + if (!state.available || !supportsDeviceScreen || (!viewing && (deviceHadLease || now() - deviceStartedAt >= 30000))) { pauseViewing() lease = null viewing = false continue } } - if(viewing && activeLease != null && now() >= nextFrame) - { + if (viewing && activeLease != null && now() >= nextFrame) { val ticket = ScreenTicket(generation, state, activeLease.id) - val frame = try { capture(activeLease) } - catch(exception: CancellationException) { throw exception } - catch(exception: Exception) { null } - if(frame != null && frame.captureMode == state.captureMode && ticket.valid(generation, screen(), lease?.id, now(), leaseExpiresAt) && !ended) - { - try - { + val frame = try { + capture(activeLease) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + null + } + if (frame != null && frame.captureMode == state.captureMode && ticket.valid(generation, screen(), lease?.id, now(), leaseExpiresAt) && !ended) { + try { // This check is deliberately adjacent to the cancellable upload, after JPEG encoding. - if(ticket.valid(generation, screen(), lease?.id, now(), leaseExpiresAt) && !ended) + if (ticket.valid(generation, screen(), lease?.id, now(), leaseExpiresAt) && !ended) api.screen(session, activeLease.id, sequence++, frame) - } - catch(exception: CancellationException) { throw exception } - catch(exception: Exception) - { + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { lease = null viewing = false - if(state.captureMode == CaptureMode.DEVICE_SCREEN) pauseViewing() + if (state.captureMode == CaptureMode.DEVICE_SCREEN) pauseViewing() nextHeartbeat = now() + heartbeatInterval - if(exception is UiApiException && exception.httpStatus == 401) { onUnauthorized(); break } + if (exception is UiApiException && exception.httpStatus == 401) { + onUnauthorized(); break + } } } nextFrame = now() + activeLease.intervalMillis } - val deadline = if(viewing) minOf(nextHeartbeat, nextFrame, leaseExpiresAt) else - if(state.captureMode == CaptureMode.DEVICE_SCREEN) minOf(nextHeartbeat, deviceStartedAt + 30000) else nextHeartbeat + val deadline = if (viewing) minOf(nextHeartbeat, nextFrame, leaseExpiresAt) else + if (state.captureMode == CaptureMode.DEVICE_SCREEN) minOf(nextHeartbeat, deviceStartedAt + 30000) else nextHeartbeat withTimeoutOrNull((deadline - now()).coerceIn(1, 15000)) { changed.receive() } } + } finally { + observer.cancel(); changed.close(); viewing = false; lease = null; running = null; onDeviceViewingStopped() } - finally { observer.cancel(); changed.close(); viewing = false; lease = null; running = null; onDeviceViewingStopped() } } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/OpenSourceLicenses.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/OpenSourceLicenses.kt index 20e0424..f9bcb31 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/OpenSourceLicenses.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/OpenSourceLicenses.kt @@ -23,14 +23,14 @@ import androidx.compose.ui.window.DialogProperties import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults -/** Secondary settings entry; library definitions and complete license texts are bundled resources. */ +/******************************************************************************* + ** Secondary settings entry; library definitions and complete license texts are bundled resources. + *******************************************************************************/ @Composable -fun OpenSourceLicensesButton() -{ +fun OpenSourceLicensesButton() { var visible by rememberSaveable { mutableStateOf(false) } TextButton(onClick = { visible = true }) { Text("Open-source licenses") } - if(visible) - { + if (visible) { Dialog(onDismissRequest = { visible = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { Surface(modifier = Modifier.fillMaxSize()) diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/OrientationPolicy.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/OrientationPolicy.kt index 94db359..bfeaf95 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/OrientationPolicy.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/OrientationPolicy.kt @@ -15,37 +15,53 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -/** Device-local display policy; never changes Android's global rotation setting. */ -enum class OrientationPolicy(val label: String, val requested: Int) -{ +/******************************************************************************* + ** Device-local display policy; never changes Android's global rotation setting. + *******************************************************************************/ +enum class OrientationPolicy(val label: String, val requested: Int) { SYSTEM("System default", ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED), PORTRAIT("Portrait", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT), LANDSCAPE("Landscape", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE), REVERSE_PORTRAIT("Reverse portrait", ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT), REVERSE_LANDSCAPE("Reverse landscape", ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE); - companion object - { + /******************************************************************************* + ** Share preference parsing between startup restoration and the operator's display controls. + *******************************************************************************/ + companion object { + /******************************************************************************* + ** Fall back to portrait for missing or invalid preferences while preserving every explicit supported choice. + *******************************************************************************/ fun parse(value: String?) = entries.firstOrNull { it.name == value } ?: PORTRAIT + + /******************************************************************************* + ** Read only this application's local display preference, leaving Android's global rotation setting untouched. + *******************************************************************************/ fun read(context: Context) = parse(context.getSharedPreferences("host", Context.MODE_PRIVATE).getString("orientation", null)) } - fun select(activity: Activity) - { + /******************************************************************************* + ** Persist the choice before changing orientation because Android may immediately recreate the activity. + *******************************************************************************/ + fun select(activity: Activity) { // Persist before the platform potentially recreates the Activity. Its ViewModels survive rotation. activity.getSharedPreferences("host", Context.MODE_PRIVATE).edit().putString("orientation", name).apply() apply(activity) } - fun apply(activity: Activity) - { - if(activity.requestedOrientation != requested) activity.requestedOrientation = requested + /******************************************************************************* + ** Avoid requesting redundant orientation changes that could recreate the host unnecessarily. + *******************************************************************************/ + fun apply(activity: Activity) { + if (activity.requestedOrientation != requested) activity.requestedOrientation = requested } } +/******************************************************************************* + ** Offer device-local orientation choices through the same persisted policy used at activity startup. + *******************************************************************************/ @Composable -fun OrientationSetting(activity: Activity) -{ +fun OrientationSetting(activity: Activity) { var selection by remember(activity) { mutableStateOf(OrientationPolicy.read(activity)) } var expanded by remember { mutableStateOf(false) } Box() diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/PhotoUploadViewModel.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/PhotoUploadViewModel.kt index 9744ce9..230bad6 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/PhotoUploadViewModel.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/PhotoUploadViewModel.kt @@ -23,11 +23,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject -/** One photo intent belongs to one authenticated process. Rotation retains it; logout never reassigns its actor. */ -internal class PhotoUploadViewModel(private val api: RecordFilesTransport, private val session: String, +/******************************************************************************* + ** One photo intent belongs to one authenticated process. Rotation retains it; logout never reassigns its actor. + *******************************************************************************/ +internal class PhotoUploadViewModel( + private val api: RecordFilesTransport, private val session: String, private val target: RecordPhotoTarget, private val directory: File, private val io: CoroutineDispatcher = Dispatchers.IO, - private val isCurrentSession: () -> Boolean) : ViewModel() -{ + private val isCurrentSession: () -> Boolean +) : ViewModel() { var limits by mutableStateOf(null) private set var checking by mutableStateOf(true) @@ -52,24 +55,33 @@ internal class PhotoUploadViewModel(private val api: RecordFilesTransport, priva private val copyingInput = AtomicReference() val pending: Boolean get() = captureFile != null || intent != null && !saved - init { checkCapabilities() } + init { + checkCapabilities() + } - /** Capability denial hides photo actions without disabling the rest of the process. */ - fun checkCapabilities() - { - if(busy || released) return + /******************************************************************************* + ** Capability denial hides photo actions without disabling the rest of the process. + *******************************************************************************/ + fun checkCapabilities() { + if (busy || released) return operation = viewModelScope.launch { checking = true - try { checkSession(); limits = api.capabilities(session, target) } - catch(exception: CancellationException) { throw exception } - catch(exception: Exception) { limits = null; message = "Photo upload is unavailable. Check the connection or ask your administrator." } - finally { checking = false } + try { + checkSession(); limits = api.capabilities(session, target) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + limits = null; message = "Photo upload is unavailable. Check the connection or ask your administrator." + } finally { + checking = false + } } } - /** Allocate only a private app-cache original for the camera's temporary URI grant. */ - fun prepareCamera(): File - { + /******************************************************************************* + ** Allocate only a private app-cache original for the camera's temporary URI grant. + *******************************************************************************/ + fun prepareCamera(): File { checkSession() check(!pending && !busy && limits?.mediaTypes?.contains("image/jpeg") == true) clearSavedIntent() @@ -79,18 +91,17 @@ internal class PhotoUploadViewModel(private val api: RecordFilesTransport, priva return File(directory, "photo-${UUID.randomUUID()}.jpg").also { ownedFiles.add(it); captureFile = it } } - /** A cancelled camera produces no upload; successful originals are hashed without transformation. */ - fun cameraResult(success: Boolean) - { + /******************************************************************************* + ** A cancelled camera produces no upload; successful originals are hashed without transformation. + *******************************************************************************/ + fun cameraResult(success: Boolean) { val file = captureFile ?: return - if(released || !isCurrentSession()) - { + if (released || !isCurrentSession()) { deleteOwned(file) captureFile = null return } - if(!success) - { + if (!success) { deleteOwned(file) captureFile = null message = "Photo capture cancelled." @@ -99,46 +110,47 @@ internal class PhotoUploadViewModel(private val api: RecordFilesTransport, priva stage(file, "image/jpeg", "CAMERA") } - /** The selected URI is read immediately under its temporary grant; no broad media permission is needed. */ - fun galleryResult(mediaType: String, open: () -> InputStream?) - { - if(pending || busy || released || !isCurrentSession()) return + /******************************************************************************* + ** The selected URI is read immediately under its temporary grant; no broad media permission is needed. + *******************************************************************************/ + fun galleryResult(mediaType: String, open: () -> InputStream?) { + if (pending || busy || released || !isCurrentSession()) return val policy = limits ?: return - if(!mediaType.startsWith("image/") || mediaType !in policy.mediaTypes) - { + if (!mediaType.startsWith("image/") || mediaType !in policy.mediaTypes) { message = "This photo type is not accepted by the server." return } clearSavedIntent() directory.mkdirs() - val extension = when(mediaType) { "image/jpeg" -> "jpg"; "image/png" -> "png"; "image/webp" -> "webp"; else -> "image" } + val extension = when (mediaType) { + "image/jpeg" -> "jpg"; "image/png" -> "png"; "image/webp" -> "webp"; else -> "image" + } val file = File(directory, "photo-${UUID.randomUUID()}.$extension") ownedFiles.add(file) captureFile = file stage(file, mediaType, "GALLERY", open) } - /** Copy/hash with a strict server/local byte bound and delete only this newly created temporary file on failure. */ - private fun stage(file: File, mediaType: String, origin: String, open: (() -> InputStream?)? = null) - { + /******************************************************************************* + ** Copy/hash with a strict server/local byte bound and delete only this newly created temporary file on failure. + *******************************************************************************/ + private fun stage(file: File, mediaType: String, origin: String, open: (() -> InputStream?)? = null) { val policy = limits ?: return busy = true operation = viewModelScope.launch { var startCameraUpload = false - try - { + try { checkSession() val staged = withContext(io) { - if(open != null) (open() ?: error("Photo could not be opened.")).use { input -> + if (open != null) (open() ?: error("Photo could not be opened.")).use { input -> copyingInput.set(input) file.outputStream().use { output -> val buffer = ByteArray(65536) var total = 0L - while(true) - { + while (true) { ensureActive() val count = input.read(buffer) - if(count < 0) break + if (count < 0) break total += count require(total <= policy.maximumFileBytes) { "Photo exceeds the server limit." } output.write(buffer, 0, count) @@ -154,35 +166,36 @@ internal class PhotoUploadViewModel(private val api: RecordFilesTransport, priva saved = false uploadId = null createAttempted = false - message = if(origin == "CAMERA") "Uploading photo…" else "Photo ready to upload." + message = if (origin == "CAMERA") "Uploading photo…" else "Photo ready to upload." startCameraUpload = origin == "CAMERA" - } - catch(exception: CancellationException) { deleteOwned(file); throw exception } - catch(exception: Exception) - { + } catch (exception: CancellationException) { + deleteOwned(file); throw exception + } catch (exception: Exception) { deleteOwned(file) message = "The photo could not be prepared or exceeds the server's size limit. Choose another photo." + } finally { + copyingInput.set(null); captureFile = null; busy = false } - finally { copyingInput.set(null); captureFile = null; busy = false } - if(startCameraUpload) upload() + if (startCameraUpload) upload() } } - /** Reconnect by recovering the same manifest/status; never allocate a second intent after a lost response. */ - fun upload() - { + /******************************************************************************* + ** Reconnect by recovering the same manifest/status; never allocate a second intent after a lost response. + *******************************************************************************/ + fun upload() { val original = intent ?: return - if(busy || saved || released) return + if (busy || saved || released) return busy = true operation = viewModelScope.launch { - try - { + try { checkSession() var status = recover(original) - if(status.getString("state") == "COMPLETE") { acceptReceipt(original, status); return@launch } + if (status.getString("state") == "COMPLETE") { + acceptReceipt(original, status); return@launch + } require(status.getString("state") in setOf("OPEN", "FAILED", "FINALIZING")) - if(status.getString("state") != "FINALIZING") - { + if (status.getString("state") != "FINALIZING") { withContext(io) { require(original.file.length() == original.size && hash(original.file) == original.sha256) } val frozen = PhotoUploadLimits.parse(status.getJSONObject("limits")) require(original.size <= frozen.maximumFileBytes && original.mediaType in frozen.mediaTypes) @@ -194,11 +207,10 @@ internal class PhotoUploadViewModel(private val api: RecordFilesTransport, priva require(known.all { it in 0 until count }) withContext(io) { RandomAccessFile(original.file, "r").use { file -> - for(index in 0 until count) - { + for (index in 0 until count) { ensureActive() checkSession() - if(index in known) continue + if (index in known) continue val offset = index.toLong() * frozen.chunkBytes val bytes = ByteArray(minOf(frozen.chunkBytes.toLong(), original.size - offset).toInt()) file.seek(offset) @@ -211,29 +223,35 @@ internal class PhotoUploadViewModel(private val api: RecordFilesTransport, priva checkSession() status = api.finalize(session, uploadId!!, original.requestId) acceptReceipt(original, status) + } catch (exception: CancellationException) { + message = "Upload paused. Check the same upload when connected."; throw exception + } catch (exception: Exception) { + message = "Upload is not confirmed. Retry/check the same upload when connected. Do not select the photo again." + } finally { + busy = false } - catch(exception: CancellationException) { message = "Upload paused. Check the same upload when connected."; throw exception } - catch(exception: Exception) { message = "Upload is not confirmed. Retry/check the same upload when connected. Do not select the photo again." } - finally { busy = false } } } - /** Discard recovers an uncertain create before cancelling; it never claims to delete retained originals. */ - fun discard() - { + /******************************************************************************* + ** Discard recovers an uncertain create before cancelling; it never claims to delete retained originals. + *******************************************************************************/ + fun discard() { val original = intent ?: return - if(busy || released) return + if (busy || released) return busy = true operation = viewModelScope.launch { - try - { + try { checkSession() - if(createAttempted) - { + if (createAttempted) { val status = recover(original) - if(status.getString("state") == "COMPLETE") { acceptReceipt(original, status); return@launch } + if (status.getString("state") == "COMPLETE") { + acceptReceipt(original, status); return@launch + } val cancelled = api.cancel(session, uploadId!!) - if(cancelled.getString("state") == "COMPLETE") { acceptReceipt(original, cancelled); return@launch } + if (cancelled.getString("state") == "COMPLETE") { + acceptReceipt(original, cancelled); return@launch + } require(cancelled.getString("state") in setOf("CANCELLED", "EXPIRED")) } deleteOwned(original.file) @@ -241,21 +259,28 @@ internal class PhotoUploadViewModel(private val api: RecordFilesTransport, priva uploadId = null createAttempted = false message = "Photo discarded." + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + message = "Cancellation is not confirmed. Keep this screen and retry when connected." + } finally { + busy = false } - catch(exception: CancellationException) { throw exception } - catch(exception: Exception) { message = "Cancellation is not confirmed. Keep this screen and retry when connected." } - finally { busy = false } } } - /** Backgrounding cancels transport but preserves the exact intent for explicit foreground recovery. */ - fun pause() { operation?.cancel() } + /******************************************************************************* + ** Backgrounding cancels transport but preserves the exact intent for explicit foreground recovery. + *******************************************************************************/ + fun pause() { + operation?.cancel() + } - /** A new capture cannot reactivate a completed intent whose temporary original was already removed. */ - private fun clearSavedIntent() - { - if(saved) - { + /******************************************************************************* + ** A new capture cannot reactivate a completed intent whose temporary original was already removed. + *******************************************************************************/ + private fun clearSavedIntent() { + if (saved) { intent = null uploadId = null createAttempted = false @@ -263,54 +288,60 @@ internal class PhotoUploadViewModel(private val api: RecordFilesTransport, priva } } - /** Recover creation only with the same actor and immutable request; record its server identity immediately. */ - private suspend fun recover(original: PhotoUploadIntent): JSONObject - { + /******************************************************************************* + ** Recover creation only with the same actor and immutable request; record its server identity immediately. + *******************************************************************************/ + private suspend fun recover(original: PhotoUploadIntent): JSONObject { checkSession() val id = uploadId - val status = if(id == null) - { + val status = if (id == null) { createAttempted = true api.create(session, original) - } - else api.status(session, id) + } else api.status(session, id) val returned = status.getString("uploadId") require(UUID.fromString(returned).toString() == returned && (id == null || id == returned)) uploadId = returned return status } - /** A matching COMPLETE receipt alone permits local cleanup and the Saved label. */ - private fun acceptReceipt(original: PhotoUploadIntent, status: JSONObject) - { + /******************************************************************************* + ** A matching COMPLETE receipt alone permits local cleanup and the Saved label. + *******************************************************************************/ + private fun acceptReceipt(original: PhotoUploadIntent, status: JSONObject) { checkSession() require(status.getString("uploadId") == uploadId) val receipt = original.verifyReceipt(status) saved = true deleteOwned(original.file) - message = if(receipt.getJSONArray("items").getJSONObject(0).getString("lifecycleAtCommit") == "QUARANTINED") + message = if (receipt.getJSONArray("items").getJSONObject(0).getString("lifecycleAtCommit") == "QUARANTINED") "Saved photo. Awaiting server review." else "Saved photo." } - /** A rotated Activity is allowed; a changed/expired authenticated session is not. */ - private fun checkSession() { check(!released && isCurrentSession()) { "The photo session has ended." } } - - /** Only files allocated by this instance are eligible for cleanup; the selected gallery original is never owned. */ - private fun deleteOwned(file: File) - { - if(file !in ownedFiles) return - if(file.delete() || !file.exists()) ownedFiles.remove(file) + /******************************************************************************* + ** A rotated Activity is allowed; a changed/expired authenticated session is not. + *******************************************************************************/ + private fun checkSession() { + check(!released && isCurrentSession()) { "The photo session has ended." } } - /** Destruction differs from rotation/background: cancel I/O, close the active source, then remove our temporary originals. */ - override fun onCleared() - { + /******************************************************************************* + ** Only files allocated by this instance are eligible for cleanup; the selected gallery original is never owned. + *******************************************************************************/ + private fun deleteOwned(file: File) { + if (file !in ownedFiles) return + if (file.delete() || !file.exists()) ownedFiles.remove(file) + } + + /******************************************************************************* + ** Destruction differs from rotation/background: cancel I/O, close the active source, then remove our temporary originals. + *******************************************************************************/ + override fun onCleared() { released = true val work = operation work?.cancel() runCatching { copyingInput.getAndSet(null)?.close() } // Completion runs after the copy's use/finally has closed its writer, avoiding delete/write races. - if(work == null) ownedFiles.toList().forEach(::deleteOwned) + if (work == null) ownedFiles.toList().forEach(::deleteOwned) else work.invokeOnCompletion { ownedFiles.toList().forEach(::deleteOwned) } captureFile = null intent = null @@ -319,16 +350,16 @@ internal class PhotoUploadViewModel(private val api: RecordFilesTransport, priva super.onCleared() } - /** Hash originals using fixed memory, preserving camera/gallery bytes exactly. */ - private fun hash(file: File): String - { + /******************************************************************************* + ** Hash originals using fixed memory, preserving camera/gallery bytes exactly. + *******************************************************************************/ + private fun hash(file: File): String { val digest = MessageDigest.getInstance("SHA-256") file.inputStream().use { input -> val buffer = ByteArray(65536) - while(true) - { + while (true) { val size = input.read(buffer) - if(size < 0) break + if (size < 0) break digest.update(buffer, 0, size) } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt index 1c8f31d..2679f85 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordFilesApi.kt @@ -29,14 +29,18 @@ import okhttp3.Response import org.json.JSONArray import org.json.JSONObject -/** The semantic widget supplies the parent; the server independently authorizes it on every operation. */ -internal data class RecordPhotoTarget(val tableName: String, val recordId: String, val title: String?, val instructions: String?) -{ - companion object - { - /** Refuse missing/coerced identities; never derive a parent from an arbitrary form field. */ - fun parse(values: Map): RecordPhotoTarget - { +/******************************************************************************* + ** The semantic widget supplies the parent; the server independently authorizes it on every operation. + *******************************************************************************/ +internal data class RecordPhotoTarget(val tableName: String, val recordId: String, val title: String?, val instructions: String?) { + /******************************************************************************* + ** Keep record-file boundary validation and manifest utilities alongside their transport data contracts. + *******************************************************************************/ + companion object { + /******************************************************************************* + ** Refuse missing/coerced identities; never derive a parent from an arbitrary form field. + *******************************************************************************/ + fun parse(values: Map): RecordPhotoTarget { val parent = values["parent"] as? Map<*, *> ?: error("Missing photo target.") val table = parent["tableName"] as? String ?: error("Invalid photo target.") val record = parent["recordId"] as? String ?: error("Invalid photo target.") @@ -50,14 +54,18 @@ internal data class RecordPhotoTarget(val tableName: String, val recordId: Strin } } -/** Frozen server limits are bounded again locally before allocating or reading original bytes. */ -internal data class PhotoUploadLimits(val maximumFileBytes: Long, val chunkBytes: Int, val mediaTypes: Set) -{ - companion object - { - /** This client sends one original at a time; both per-file and total limits still apply. */ - fun parse(value: JSONObject): PhotoUploadLimits - { +/******************************************************************************* + ** Frozen server limits are bounded again locally before allocating or reading original bytes. + *******************************************************************************/ +internal data class PhotoUploadLimits(val maximumFileBytes: Long, val chunkBytes: Int, val mediaTypes: Set) { + /******************************************************************************* + ** Keep record-file boundary validation and manifest utilities alongside their transport data contracts. + *******************************************************************************/ + companion object { + /******************************************************************************* + ** This client sends one original at a time; both per-file and total limits still apply. + *******************************************************************************/ + fun parse(value: JSONObject): PhotoUploadLimits { val maximum = minOf(value.getLong("maximumFileBytes"), value.getLong("maximumTotalBytes")) val chunk = value.getInt("chunkBytes") require(maximum in 1..20L * 1024 * 1024 && chunk in 65536..4 * 1024 * 1024 && value.getInt("maximumItems") >= 1) @@ -67,14 +75,18 @@ internal data class PhotoUploadLimits(val maximumFileBytes: Long, val chunkBytes } } -/** Immutable intent survives retries; source bytes are private temporary originals, never resized. */ -internal data class PhotoUploadIntent(val target: RecordPhotoTarget, val file: File, val mediaType: String, +/******************************************************************************* + ** Immutable intent survives retries; source bytes are private temporary originals, never resized. + *******************************************************************************/ +internal data class PhotoUploadIntent( + val target: RecordPhotoTarget, val file: File, val mediaType: String, val origin: String, val size: Long, val sha256: String, val requestId: String = UUID.randomUUID().toString(), - val itemId: String = UUID.randomUUID().toString()) -{ - /** Replays use exactly this manifest and identity. */ - fun manifest(): JSONObject - { + val itemId: String = UUID.randomUUID().toString() +) { + /******************************************************************************* + ** Replays use exactly this manifest and identity. + *******************************************************************************/ + fun manifest(): JSONObject { require(origin in setOf("CAMERA", "GALLERY")) val item = JSONObject().put("itemId", itemId).put("position", 0).put("fileName", file.name) .put("declaredMediaType", mediaType).put("sizeBytes", size).put("sha256", sha256).put("origin", origin) @@ -83,9 +95,10 @@ internal data class PhotoUploadIntent(val target: RecordPhotoTarget, val file: F .put("items", JSONArray().put(item)).also { target.title?.let { title -> it.put("title", title) } } } - /** Saved requires the complete exact-original association receipt, not only a successful HTTP status. */ - fun verifyReceipt(status: JSONObject): JSONObject - { + /******************************************************************************* + ** Saved requires the complete exact-original association receipt, not only a successful HTTP status. + *******************************************************************************/ + fun verifyReceipt(status: JSONObject): JSONObject { require(status.getString("state") == "COMPLETE") { "The upload is not complete." } val receipt = status.getJSONObject("receipt") require(receipt.getString("uploadId") == status.getString("uploadId") && receipt.getString("state") == "COMPLETE") @@ -100,89 +113,145 @@ internal data class PhotoUploadIntent(val target: RecordPhotoTarget, val file: F } } -/** Native Record Files 1.x client. It shares QQQ authentication and the registered semantic extension. */ -internal interface RecordFilesTransport -{ +/******************************************************************************* + ** Native Record Files 1.x client. It shares QQQ authentication and the registered semantic extension. + *******************************************************************************/ +internal interface RecordFilesTransport { + /******************************************************************************* + ** Discover upload eligibility and bounded limits for this authenticated target before staging media. + *******************************************************************************/ suspend fun capabilities(session: String, target: RecordPhotoTarget): PhotoUploadLimits? + + /******************************************************************************* + ** Create or recover the upload intent using its existing identity rather than starting a duplicate upload. + *******************************************************************************/ suspend fun create(session: String, intent: PhotoUploadIntent): JSONObject + + /******************************************************************************* + ** Read the existing upload's authoritative receipt so retry decisions preserve event identity. + *******************************************************************************/ suspend fun status(session: String, uploadId: String): JSONObject + + /******************************************************************************* + ** Send one indexed bounded chunk for the existing upload item without changing its manifest. + *******************************************************************************/ suspend fun chunk(session: String, uploadId: String, itemId: String, index: Int, bytes: ByteArray): JSONObject + + /******************************************************************************* + ** Commit the existing upload with its stable request identity so a lost response remains recoverable. + *******************************************************************************/ suspend fun finalize(session: String, uploadId: String, requestId: String): JSONObject + + /******************************************************************************* + ** Ask the server to cancel the existing upload; local deletion waits for authoritative confirmation. + *******************************************************************************/ suspend fun cancel(session: String, uploadId: String): JSONObject } -/** Transport-only implementation keeps capture and retry lifecycle independent of HTTP. */ -internal class RecordFilesApi(private val server: String, private val headers: () -> Map, - private val compatibility: UiApiCompatibility, onOutcome: (RequestOutcome) -> Unit = {}) : RecordFilesTransport -{ +/******************************************************************************* + ** Transport-only implementation keeps capture and retry lifecycle independent of HTTP. + *******************************************************************************/ +internal class RecordFilesApi( + private val server: String, private val headers: () -> Map, + private val compatibility: UiApiCompatibility, onOutcome: (RequestOutcome) -> Unit = {} +) : RecordFilesTransport { private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) .callTimeout(30, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).build() - @Volatile private var captureContractVerified = false - /** Absence is optional: hide capture actions, while ordinary scanner functionality remains available. */ + @Volatile + private var captureContractVerified = false + + /******************************************************************************* + ** Absence is optional: hide capture actions, while ordinary scanner functionality remains available. + *******************************************************************************/ override suspend fun capabilities(session: String, target: RecordPhotoTarget): PhotoUploadLimits? = withContext(Dispatchers.IO) { val version = compatibility.verify() val extension = version.extensions.singleOrNull { it.name == "record-files" } ?: return@withContext null - if(extension.pathPrefix != "/qbit/record-files" || !Regex("1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)").matches(extension.version)) return@withContext null + if (extension.pathPrefix != "/qbit/record-files" || !Regex("1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)").matches(extension.version)) return@withContext null val bridge = version.extensions.singleOrNull { it.name == "mobile-record-files" } ?: return@withContext null - if(bridge.pathPrefix != "/qbit/mobile-record-files" || !supportedVersion(bridge.version)) return@withContext null + if (bridge.pathPrefix != "/qbit/mobile-record-files" || !supportedVersion(bridge.version)) return@withContext null val descriptor = execute(null, Request.Builder().url(server.trimEnd('/') + "/qqq/v1/qbit/mobile-record-files/contract").get()) - if(!supportedVersion(descriptor.getString("version")) || descriptor.getString("blockType") != "RECORD_FILES_CAPTURE" || descriptor.getString("widgetType") != "composite") return@withContext null + if (!supportedVersion(descriptor.getString("version")) || descriptor.getString("blockType") != "RECORD_FILES_CAPTURE" || descriptor.getString("widgetType") != "composite") return@withContext null captureContractVerified = true val url = endpoint("/capabilities").toHttpUrl().newBuilder().addQueryParameter("tableName", target.tableName).addQueryParameter("recordId", target.recordId).build() val value = execute(session, Request.Builder().url(url).get()) - if(!value.getBoolean("canUpload")) return@withContext null + if (!value.getBoolean("canUpload")) return@withContext null require(supportedVersion(value.getString("version")) && value.getBoolean("resumable") && !value.getBoolean("offline")) PhotoUploadLimits.parse(value.getJSONObject("limits")) } - /** Create is idempotent under the immutable actor/request manifest. */ - override suspend fun create(session: String, intent: PhotoUploadIntent): JSONObject = execute(session, - mutation("/uploads").post(intent.manifest().toString().toRequestBody(JSON))) + /******************************************************************************* + ** Create is idempotent under the immutable actor/request manifest. + *******************************************************************************/ + override suspend fun create(session: String, intent: PhotoUploadIntent): JSONObject = execute( + session, + mutation("/uploads").post(intent.manifest().toString().toRequestBody(JSON)) + ) - /** Status is safe after a lost chunk or finalize response and never creates a second upload. */ - override suspend fun status(session: String, uploadId: String): JSONObject = execute(session, - Request.Builder().url(endpoint("/uploads/${uuid(uploadId)}")).get()) + /******************************************************************************* + ** Status is safe after a lost chunk or finalize response and never creates a second upload. + *******************************************************************************/ + override suspend fun status(session: String, uploadId: String): JSONObject = execute( + session, + Request.Builder().url(endpoint("/uploads/${uuid(uploadId)}")).get() + ) - /** Binary chunks carry exact length and checksum; repeating the same index/bytes is safe. */ - override suspend fun chunk(session: String, uploadId: String, itemId: String, index: Int, bytes: ByteArray): JSONObject - { + /******************************************************************************* + ** Binary chunks carry exact length and checksum; repeating the same index/bytes is safe. + *******************************************************************************/ + override suspend fun chunk(session: String, uploadId: String, itemId: String, index: Int, bytes: ByteArray): JSONObject { require(index >= 0 && bytes.isNotEmpty() && bytes.size <= 4 * 1024 * 1024) - return execute(session, mutation("/uploads/${uuid(uploadId)}/items/${uuid(itemId)}/chunks/$index") - .header("X-Chunk-SHA256", sha256(bytes)).put(bytes.toRequestBody(BINARY))) + return execute( + session, mutation("/uploads/${uuid(uploadId)}/items/${uuid(itemId)}/chunks/$index") + .header("X-Chunk-SHA256", sha256(bytes)).put(bytes.toRequestBody(BINARY)) + ) } - /** Finalization repeats only with the original request identity. */ - override suspend fun finalize(session: String, uploadId: String, requestId: String): JSONObject = execute(session, - mutation("/uploads/${uuid(uploadId)}/finalize").header("Idempotency-Key", uuid(requestId)).post(EMPTY)) + /******************************************************************************* + ** Finalization repeats only with the original request identity. + *******************************************************************************/ + override suspend fun finalize(session: String, uploadId: String, requestId: String): JSONObject = execute( + session, + mutation("/uploads/${uuid(uploadId)}/finalize").header("Idempotency-Key", uuid(requestId)).post(EMPTY) + ) - /** Cancellation only removes server staging; committed originals remain retained. */ - override suspend fun cancel(session: String, uploadId: String): JSONObject = execute(session, - mutation("/uploads/${uuid(uploadId)}/cancel").post(EMPTY)) + /******************************************************************************* + ** Cancellation only removes server staging; committed originals remain retained. + *******************************************************************************/ + override suspend fun cancel(session: String, uploadId: String): JSONObject = execute( + session, + mutation("/uploads/${uuid(uploadId)}/cancel").post(EMPTY) + ) - /** Protocol headers never substitute for the user's current session and verified device. */ + /******************************************************************************* + ** Protocol headers never substitute for the user's current session and verified device. + *******************************************************************************/ private fun mutation(path: String) = Request.Builder().url(endpoint(path)).header("X-Record-Files-Request", "1") - /** Routes are fixed contract paths; opaque upload IDs are validated before interpolation. */ + /******************************************************************************* + ** Routes are fixed contract paths; opaque upload IDs are validated before interpolation. + *******************************************************************************/ private fun endpoint(path: String) = server.trimEnd('/') + "/qqq/v1/qbit/record-files" + path - /** Canonical UUID spelling prevents path injection and accidental identity changes. */ - private fun uuid(value: String): String - { + /******************************************************************************* + ** Canonical UUID spelling prevents path injection and accidental identity changes. + *******************************************************************************/ + private fun uuid(value: String): String { require(UUID.fromString(value).toString() == value) return value } - /** Cancellation closes transport; bounded JSON parsing cannot allocate an arbitrary server body. */ + /******************************************************************************* + ** Cancellation closes transport; bounded JSON parsing cannot allocate an arbitrary server body. + *******************************************************************************/ private suspend fun execute(session: String?, builder: Request.Builder): JSONObject = withContext(Dispatchers.IO) { require(session?.isNotBlank() != false && compatibility.server == server) val extension = compatibility.verify().extensions.singleOrNull { it.name == "record-files" } require(extension != null && extension.pathPrefix == "/qbit/record-files" && supportedVersion(extension.version)) { "Record Files is unavailable on this server." } builder.header("X-QQQ-Client", ANDROID_CLIENT).header("Cache-Control", "no-store") - if(session != null) - { + if (session != null) { check(captureContractVerified) { "The mobile photo contract has not been verified." } builder.header("Cookie", "sessionUUID=$session") headers().forEach { (name, value) -> builder.header(name, value) } @@ -190,14 +259,24 @@ internal class RecordFilesApi(private val server: String, private val headers: ( val call = client.newCall(builder.build()) val response = suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { call.cancel() } - call.enqueue(object : Callback - { - override fun onFailure(call: Call, e: IOException) { if(continuation.isActive) continuation.resumeWithException(e) } - override fun onResponse(call: Call, response: Response) { continuation.resume(response) { _, value, _ -> value.close() } } + call.enqueue(object : Callback { + /******************************************************************************* + ** Deliver transport failure only to a live continuation so cancelled work stays cancelled. + *******************************************************************************/ + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) continuation.resumeWithException(e) + } + + /******************************************************************************* + ** Transfer response ownership and close the response if coroutine cancellation races delivery. + *******************************************************************************/ + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) { _, value, _ -> value.close() } + } }) } response.use { - if(!it.isSuccessful) throw UiApiException.from(it) + if (!it.isSuccessful) throw UiApiException.from(it) require(it.code == 200) { "Unexpected upload response status." } val source = it.body?.source() ?: error("Missing upload response.") source.request(262145) @@ -206,16 +285,22 @@ internal class RecordFilesApi(private val server: String, private val headers: ( } } - companion object - { + /******************************************************************************* + ** Keep record-file boundary validation and manifest utilities alongside their transport data contracts. + *******************************************************************************/ + companion object { private val JSON = "application/json".toMediaType() private val BINARY = "application/octet-stream".toMediaType() private val EMPTY: RequestBody = ByteArray(0).toRequestBody(null) - /** Additive 1.x contracts preserve the six tested ingestion operations. */ + /******************************************************************************* + ** Additive 1.x contracts preserve the six tested ingestion operations. + *******************************************************************************/ private fun supportedVersion(value: String) = Regex("1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)").matches(value) - /** Checksums are lowercase SHA-256 as required by the registered ingestion contract. */ + /******************************************************************************* + ** Checksums are lowercase SHA-256 as required by the registered ingestion contract. + *******************************************************************************/ fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordPhotoWidget.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordPhotoWidget.kt index 9d5d0f9..6a36800 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordPhotoWidget.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/RecordPhotoWidget.kt @@ -30,15 +30,15 @@ import androidx.navigation.NavBackStackEntry import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters import java.io.File -/** Native capture is opt-in metadata plus current server capabilities; receipts do not advance business steps. */ +/******************************************************************************* + ** Native capture is opt-in metadata plus current server capabilities; receipts do not advance business steps. + *******************************************************************************/ @Composable -internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFilesApi, modifier: Modifier, disabled: Boolean) -{ +internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFilesApi, modifier: Modifier, disabled: Boolean) { val target = runCatching { RecordPhotoTarget.parse(parameters.widgetBlock.values) }.getOrNull() val host = parameters.qViewModel val process = host?.activeProcessViewModel - if(target == null || host == null || process == null) - { + if (target == null || host == null || process == null) { Text("This photo control has unsupported metadata or no active process.", modifier.padding(16.dp)) return } @@ -47,8 +47,7 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil val session = host.sessionUUID val processId = process.processUUID ?: return val processOwner = LocalViewModelStoreOwner.current as? NavBackStackEntry - if(processOwner == null) - { + if (processOwner == null) { Text("Photo capture requires a process navigation scope.", modifier.padding(16.dp)) return } @@ -56,10 +55,11 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil val key = "photo:$session:$processId:$blockId:${target.tableName.length}:${target.tableName}:${target.recordId}" val factory = remember(key, api) { - object : ViewModelProvider.Factory - { - override fun create(modelClass: Class): T - { + object : ViewModelProvider.Factory { + /******************************************************************************* + ** Construct upload state for the captured record/session rather than sharing it across unrelated widgets. + *******************************************************************************/ + override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") return PhotoUploadViewModel(api, session, target, File(application.cacheDir, "record-photos")) { host.sessionUUID == session && host.isAuthenticated && !host.isLogoutRequested && host.activeProcessViewModel === process @@ -70,22 +70,21 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil val upload: PhotoUploadViewModel = viewModel(viewModelStoreOwner = processOwner, key = key, factory = factory) val owner = LocalLifecycleOwner.current SideEffect { - process.setClientWork(key, if(upload.pending || upload.busy) "Finish, retry/check, or discard the pending photo before leaving or continuing." else null) + process.setClientWork(key, if (upload.pending || upload.busy) "Finish, retry/check, or discard the pending photo before leaving or continuing." else null) } DisposableEffect(owner, upload) { - val observer = LifecycleEventObserver { _, event -> if(event == Lifecycle.Event.ON_STOP) upload.pause() } + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) upload.pause() } owner.lifecycle.addObserver(observer) onDispose { owner.lifecycle.removeObserver(observer) upload.pause() - if(!upload.pending) process.setClientWork(key, null) + if (!upload.pending) process.setClientWork(key, null) } } val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> upload.cameraResult(success) } val gallery = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if(uri != null) - { + if (uri != null) { val media = runCatching { application.contentResolver.getType(uri) }.getOrNull().orEmpty() upload.galleryResult(media) { application.contentResolver.openInputStream(uri) } } @@ -94,34 +93,36 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil { Text(target.title ?: "Record photos", style = MaterialTheme.typography.titleMedium) target.instructions?.let { Text(it) } - when - { + when { upload.checking -> Text("Checking photo permissions…") upload.limits == null -> { Text("Photo upload is not available for this record.") TextButton(onClick = upload::checkCapabilities, enabled = !disabled && !upload.busy) { Text("Check photo availability") } } + else -> { val available = !disabled && !upload.busy && !upload.pending - if("image/jpeg" in upload.limits!!.mediaTypes) + if ("image/jpeg" in upload.limits!!.mediaTypes) Button(enabled = available, onClick = { val file = upload.prepareCamera() process.setClientWork(key, "Return from the camera and finish or discard the photo before leaving.") val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", file) - try { camera.launch(uri) } - catch(exception: Exception) { upload.cameraResult(false) } + try { + camera.launch(uri) + } catch (exception: Exception) { + upload.cameraResult(false) + } }) { Text("Take photo") } - if(upload.limits!!.mediaTypes.any { it.startsWith("image/") }) + if (upload.limits!!.mediaTypes.any { it.startsWith("image/") }) Button(enabled = available, onClick = { gallery.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }) { Text("Choose photo") } - if(upload.intent != null && !upload.saved) - { - Button(enabled = !disabled && !upload.busy, onClick = upload::upload) { Text(if(upload.hasAttemptedUpload) "Retry/check upload" else "Upload photo") } + if (upload.intent != null && !upload.saved) { + Button(enabled = !disabled && !upload.busy, onClick = upload::upload) { Text(if (upload.hasAttemptedUpload) "Retry/check upload" else "Upload photo") } TextButton(enabled = !disabled && !upload.busy, onClick = upload::discard) { Text("Discard photo") } } } } - if(upload.busy) Text("Working on photo…") - if(upload.message.isNotBlank()) Text(upload.message) - if(upload.pending) Text("This app uploads while open and connected. Keep this process open until the server confirms the photo.") + if (upload.busy) Text("Working on photo…") + if (upload.message.isNotBlank()) Text(upload.message) + if (upload.pending) Text("This app uploads while open and connected. Keep this process open until the server confirms the photo.") } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt index 41c489b..e895acc 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt @@ -4,16 +4,19 @@ package io.qrun.mobile.scanner import java.time.Instant import java.util.UUID -/** One physical scan remains one event even when its HTTP request is retried. */ +/******************************************************************************* + ** One physical scan remains one event even when its HTTP request is retried. + *******************************************************************************/ data class ScanEvent( val barcode: String, val symbology: String, val scanSource: String, val eventId: String = UUID.randomUUID().toString(), val capturedAt: String = Instant.now().toString() -) -{ - /** A fixed schema prevents arbitrary intent extras becoming process inputs. */ +) { + /******************************************************************************* + ** A fixed schema prevents arbitrary intent extras becoming process inputs. + *******************************************************************************/ fun values(): Map = mapOf( "barcode" to barcode, "symbology" to symbology, "scanSource" to scanSource, "eventId" to eventId, "capturedAt" to capturedAt diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt index 966418b..aa4a412 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt @@ -66,38 +66,59 @@ 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() -{ +/******************************************************************************* + ** 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(null) private val windowFocusObserver = android.view.ViewTreeObserver.OnWindowFocusChangeListener { focused = it } - override fun onResume() - { + /******************************************************************************* + ** 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() } + window.decorView.post { if (resumed) focused = window.decorView.hasWindowFocus() } } - override fun onPause() { resumed = false; super.onPause() } - override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus); focused = hasFocus } - override fun onDestroy() - { + /******************************************************************************* + ** Withdraw foreground eligibility before Android pauses the activity and scanner input. + *******************************************************************************/ + override fun onPause() { + resumed = false; super.onPause() + } + + /******************************************************************************* + ** 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) + if (observer.isAlive) observer.removeOnWindowFocusChangeListener(windowFocusObserver) super.onDestroy() } - companion object - { + /******************************************************************************* + ** Retain server-scoped client dependencies across activity recreation without sharing credentials between servers. + *******************************************************************************/ + companion object { private val credentialsByServer = mutableMapOf() private val storesByServer = mutableMapOf>() } - override fun onCreate(savedInstanceState: Bundle?) - { + /******************************************************************************* + ** 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) OrientationPolicy.read(this).apply(this) @@ -113,19 +134,16 @@ class ScannerActivity : ComponentActivity() { var server by remember { mutableStateOf(preferences.getString("server", null)) } val currentServer = server - if(currentServer == null) - { + if (currentServer == null) { ServerForm { value -> preferences.edit().putString("server", value).apply(); server = value } - } - else - { + } else { key(currentServer) { val scope = rememberCoroutineScope() var beforeChange by remember { mutableStateOf Unit>({}) } var changing by remember { mutableStateOf(false) } HostChrome(currentServer, onChangeServer = { - if(!changing) scope.launch { + if (!changing) scope.launch { changing = true beforeChange() preferences.edit().remove("server").apply() @@ -140,15 +158,17 @@ class ScannerActivity : ComponentActivity() } } - /** Server-scoped stores ensure switching installations cannot reuse a credential accidentally. */ + /******************************************************************************* + ** 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) - { + 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) - { + if (credentials == null) { Text("Device credentials could not be opened. Ask your administrator to reset this installation.", Modifier.padding(24.dp)) return } @@ -157,14 +177,15 @@ class ScannerActivity : ComponentActivity() QAppContainer.availableEnvironments = listOf(Environment("QQQ server", server)) QViewModel.applicationName = "qqq-mobile-scanners" QViewModel.applicationVersion = BuildConfig.VERSION_NAME - object : ViewModelProvider.Factory - { - override fun create(modelClass: Class): T - { + object : ViewModelProvider.Factory { + /******************************************************************************* + ** Capture this server's repository and preference store in its ViewModel instead of reusing another host's state. + *******************************************************************************/ + override fun create(modelClass: Class): 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() + if (requestedServer == server) credentials.headers() else emptyMap() } @Suppress("UNCHECKED_CAST") return QViewModel(repository, dataStore).apply { supportedCustomWidgetTypes = setOf("SCANNER", "RECORD_FILES_CAPTURE") } as T @@ -179,8 +200,7 @@ class ScannerActivity : ComponentActivity() } qViewModel.setNavController(rememberNavController()) val authentication = qViewModel.authenticationMetaDataLoadState - if(authentication is LoadState.Success && authentication.value !is TableBasedAuthenticationMetaData && authentication.value !is OAuth2AuthenticationMetaData) - { + 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 } @@ -202,19 +222,23 @@ class ScannerActivity : ComponentActivity() val sessions = remember(sessionApi, qViewModel.sessionUUID) { val monitoredSession = qViewModel.sessionUUID - MobileSessionController(qViewModel.sessionUUID, sessionApi, + 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) + 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) } }, + 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() + if (qViewModel.sessionUUID == monitoredSession) qViewModel.requestLogout() } - }, onDeviceViewingStopped = deviceSharing::stop, onDeviceLease = deviceSharing::boundLease) + }, onDeviceViewingStopped = deviceSharing::stop, onDeviceLease = deviceSharing::boundLease + ) } DisposableEffect(sessions) { @@ -228,7 +252,7 @@ class ScannerActivity : ComponentActivity() } LaunchedEffect(sessions, approved, qViewModel.isAuthenticated, qViewModel.isLogoutRequested) { - if(approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested) sessions.run() + if (approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested) sessions.run() } CompositionLocalProvider( LocalCompanySignOutEnabled provides true, @@ -238,33 +262,31 @@ class ScannerActivity : ComponentActivity() DeviceScreenSharingControl(deviceSharing, sessions) { approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested } - if(api.shortName.isNotBlank()) Text("Device: ${api.shortName}") - if(api.displayName.isNotBlank()) Text("Name: ${api.displayName}") + 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) - OpenSourceLicensesButton() + 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() + LocalAuthenticatedContent provides { model, content -> + DeviceGate(model, api, credentials, { approved = it }) { + Column { + ScreenViewingIndicator(sessions) + content() + } } - } }, + }, LocalCustomWidgetRenderer provides { parameters, modifier, disabled -> - if(parameters.widgetBlock.blockType == "SCANNER") - { + if (parameters.widgetBlock.blockType == "SCANNER") { ScannerWidget(parameters, modifier, disabled) true - } - else if(parameters.widgetBlock.blockType == "RECORD_FILES_CAPTURE") - { + } else if (parameters.widgetBlock.blockType == "RECORD_FILES_CAPTURE") { RecordPhotoWidget(parameters, recordFiles, modifier, disabled) true - } - else false + } else false } ) { @@ -274,9 +296,10 @@ class ScannerActivity : ComponentActivity() } } -/** URLs are origins only; release builds require HTTPS. */ -internal fun normalizeServerUrl(input: String, allowHttp: Boolean): String -{ +/******************************************************************************* + ** 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." } @@ -284,30 +307,37 @@ internal fun normalizeServerUrl(input: String, allowHttp: Boolean): String return URI(uri.scheme.lowercase(), null, uri.host.lowercase(), uri.port, "/", null, null).toASCIIString() } -/** Initial server configuration is editable until the host connects. */ +/******************************************************************************* + ** Initial server configuration is editable until the host connects. + *******************************************************************************/ @Composable -private fun ServerForm(onConnect: (String) -> Unit) -{ - var url by remember { mutableStateOf(if(BuildConfig.DEBUG) "http://127.0.0.1:8080/" else "https://") } +private fun ServerForm(onConnect: (String) -> Unit) { + var url by remember { mutableStateOf(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) 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" } + 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.") + 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. */ +/******************************************************************************* + ** 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) -{ +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("") } @@ -318,32 +348,35 @@ private fun DeviceGate(viewModel: QViewModel, api: DeviceApi, credentials: Devic onApproval(false) error = "" enrollmentConflict = false - try - { + try { status = api.status(viewModel.sessionUUID, "${Build.MANUFACTURER} ${Build.MODEL}") onApproval(status == "APPROVED") - if(status == "APPROVED") viewModel.loadQInstance() + 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." } - 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() + 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( + 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") } + if (enrollmentConflict) Button(onClick = { credentials.restartPendingEnrollment(); attempt++ }) { Text("Start new registration") } Button(onClick = { viewModel.qNavigator.requestSignOut() }) { Text("Sign out") } } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerInputSession.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerInputSession.kt index 1148a13..18623ba 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerInputSession.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerInputSession.kt @@ -1,31 +1,70 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package io.qrun.mobile.scanner -/** Device boundary; implementations deliver callbacks on the main thread. */ -internal interface ScannerDriver -{ - interface Listener - { +/******************************************************************************* + ** Device boundary; implementations deliver callbacks on the main thread. + *******************************************************************************/ +internal interface ScannerDriver { + /******************************************************************************* + ** Deliver scanner lifecycle outcomes on the main thread so widget ownership checks remain serialized. + *******************************************************************************/ + interface Listener { + /******************************************************************************* + ** Use hardware idle and pending-read state together before arming the current visible widget. + *******************************************************************************/ fun idle() + + /******************************************************************************* + ** Preserve raw barcode data and reject callbacks after ownership, readiness or generation changes. + *******************************************************************************/ fun scanned(barcode: String, symbology: String) + + /******************************************************************************* + ** Distinguish ordinary empty reads from rejected multi-barcode reads without fabricating a scan. + *******************************************************************************/ fun noData(multipleBarcodes: Boolean) + + /******************************************************************************* + ** Report hardware failure only for the active generation so stale callbacks cannot disable a replacement. + *******************************************************************************/ fun failed() } + + /******************************************************************************* + ** Acquire the device for this listener; implementations must not deliver callbacks from a released owner. + *******************************************************************************/ fun open(listener: Listener) + + /******************************************************************************* + ** Expose the device's actual read state so an idle callback cannot arm a duplicate read. + *******************************************************************************/ fun isReadPending(): Boolean + + /******************************************************************************* + ** Arm hardware capture only after the owning session has established readiness. + *******************************************************************************/ fun read() + + /******************************************************************************* + ** Cancel any outstanding read before leaving the accepting state or releasing scanner ownership. + *******************************************************************************/ fun cancelRead() + + /******************************************************************************* + ** Release callbacks and hardware ownership; failure must remain visible to the session owner. + *******************************************************************************/ fun close() } -/** One visible, resumed scanner owns the hardware; released generations cannot submit scans. */ +/******************************************************************************* + ** One visible, resumed scanner owns the hardware; released generations cannot submit scans. + *******************************************************************************/ internal class ScannerInputSession( private val createDriver: () -> ScannerDriver?, private val onScan: (String, String) -> Unit, private val onStatus: (String, Boolean) -> Unit, private val canAcceptInput: () -> Boolean = { true }, -) -{ +) { private var driver: ScannerDriver? = null private var generation = 0L private var active = false @@ -35,17 +74,22 @@ internal class ScannerInputSession( private var releaseFailed = false private var readNotice: String? = null - fun setActive(value: Boolean) - { - if(active == value) return + /******************************************************************************* + ** Advance the ownership generation before releasing or acquiring the scanner for a resumed widget. + *******************************************************************************/ + fun setActive(value: Boolean) { + if (active == value) return active = value generation++ release() - if(!value) return - if(releaseFailed) return - val current = try { createDriver() } catch(exception: Exception) { null } - if(current == null) - { + if (!value) return + if (releaseFailed) return + val current = try { + createDriver() + } catch (exception: Exception) { + null + } + if (current == null) { onStatus("Hardware scanning is unavailable. Enter a barcode and submit it manually.", false) return } @@ -53,102 +97,136 @@ internal class ScannerInputSession( readNotice = null val token = generation onStatus("Connecting to scanner…", false) - try - { - current.open(object : ScannerDriver.Listener - { + try { + current.open(object : ScannerDriver.Listener { + /******************************************************************************* + ** Bind callbacks to the exact driver and active generation that opened them. + *******************************************************************************/ private fun isCurrent() = active && generation == token && driver === current - override fun idle() - { - if(!isCurrent()) return - try - { + + /******************************************************************************* + ** Use hardware idle and pending-read state together before arming the current visible widget. + *******************************************************************************/ + override fun idle() { + if (!isCurrent()) return + try { // A read can finish without data (for example, trigger release or timeout). - if(!current.isReadPending()) armed = false + if (!current.isReadPending()) armed = false idle = true arm() + } catch (exception: Exception) { + fail() } - catch(exception: Exception) { fail() } } - override fun scanned(barcode: String, symbology: String) - { - if(!isCurrent() || !accepting || !armed || !canAcceptInput()) return + + /******************************************************************************* + ** Preserve raw barcode data and reject callbacks after ownership, readiness or generation changes. + *******************************************************************************/ + override fun scanned(barcode: String, symbology: String) { + if (!isCurrent() || !accepting || !armed || !canAcceptInput()) return armed = false accepting = false readNotice = null onStatus("Waiting for scan confirmation…", false) onScan(barcode, symbology) // IDLE may precede data. Stop any read armed in that interval before accepting more input. - try { if(driver === current && current.isReadPending()) current.cancelRead() } - catch(exception: Exception) { fail() } + try { + if (driver === current && current.isReadPending()) current.cancelRead() + } catch (exception: Exception) { + fail() + } } - override fun noData(multipleBarcodes: Boolean) - { - if(!isCurrent() || !accepting) return + + /******************************************************************************* + ** Distinguish ordinary empty reads from rejected multi-barcode reads without fabricating a scan. + *******************************************************************************/ + override fun noData(multipleBarcodes: Boolean) { + if (!isCurrent() || !accepting) return // Cancellation/empty collections are ordinary read completion. Only IDLE // and the driver's pending state may rearm; never release the hardware here. - if(multipleBarcodes) - { + if (multipleBarcodes) { readNotice = "Multiple barcodes were rejected. Scan one barcode at a time." onStatus(requireNotNull(readNotice), false) } } - override fun failed() { if(isCurrent()) fail() } + + /******************************************************************************* + ** Report hardware failure only for the active generation so stale callbacks cannot disable a replacement. + *******************************************************************************/ + override fun failed() { + if (isCurrent()) fail() + } }) + } catch (exception: Exception) { + fail() } - catch(exception: Exception) { fail() } } - fun reconnect() - { - if(!active || releaseFailed) return + /******************************************************************************* + ** Retry acquisition only when the prior hardware release was confirmed. + *******************************************************************************/ + fun reconnect() { + if (!active || releaseFailed) return setActive(false) setActive(true) } - fun setAccepting(value: Boolean) - { + /******************************************************************************* + ** Stop outstanding reads while server acknowledgment or UI readiness prevents another submission. + *******************************************************************************/ + fun setAccepting(value: Boolean) { accepting = value - if(!value && armed) - { + if (!value && armed) { armed = false - try { driver?.cancelRead() } catch(exception: Exception) { fail() } + try { + driver?.cancelRead() + } catch (exception: Exception) { + fail() + } } - if(value) arm() + if (value) arm() } - private fun arm() - { + /******************************************************************************* + ** Start at most one read after ownership, widget readiness and hardware idle agree. + *******************************************************************************/ + private fun arm() { val current = driver ?: return - if(!active || !accepting || !idle || armed || !canAcceptInput()) return - try - { - if(current.isReadPending()) return + if (!active || !accepting || !idle || armed || !canAcceptInput()) return + try { + if (current.isReadPending()) return armed = true idle = false current.read() - if(driver === current && armed) onStatus(readNotice ?: "Scanner ready. Press the handheld trigger.", false) + if (driver === current && armed) onStatus(readNotice ?: "Scanner ready. Press the handheld trigger.", false) + } catch (exception: Exception) { + fail() } - catch(exception: Exception) { fail() } } - private fun fail() - { + /******************************************************************************* + ** Invalidate callbacks before release and distinguish retryable hardware loss from unconfirmed ownership release. + *******************************************************************************/ + private fun fail() { generation++ val released = release() - onStatus(if(released) "Hardware scanning is unavailable. Reconnect the scanner or enter a barcode manually." - else "Scanner release could not be confirmed. Restart QQQ Mobile before using the scanner.", released) + onStatus( + if (released) "Hardware scanning is unavailable. Reconnect the scanner or enter a barcode manually." + else "Scanner release could not be confirmed. Restart QQQ Mobile before using the scanner.", released + ) } - private fun release(): Boolean - { + /******************************************************************************* + ** Clear local ownership before closing the driver; an unconfirmed close prevents later acquisition. + *******************************************************************************/ + private fun release(): Boolean { val previous = driver driver = null idle = false armed = false - return try { previous?.close(); true } - catch(exception: Exception) - { + return try { + previous?.close(); true + } catch (exception: Exception) { releaseFailed = true onStatus("Scanner release could not be confirmed. Restart QQQ Mobile before using the scanner.", false) false diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerMetaData.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerMetaData.kt index 5008753..237ebbb 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerMetaData.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerMetaData.kt @@ -1,16 +1,20 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package io.qrun.mobile.scanner -/** The optional presentation hints published by mobile-scanners 1.x. */ -internal data class ScannerMetaData(val manualLabel: String = "Manual barcode", val submitLabel: String = "Submit barcode", val maxLength: Int = 4096) -{ - companion object - { - fun parse(values: Map): ScannerMetaData - { +/******************************************************************************* + ** The optional presentation hints published by mobile-scanners 1.x. + *******************************************************************************/ +internal data class ScannerMetaData(val manualLabel: String = "Manual barcode", val submitLabel: String = "Submit barcode", val maxLength: Int = 4096) { + /******************************************************************************* + ** Centralize scanner metadata validation before the widget accepts server-provided controls. + *******************************************************************************/ + companion object { + /******************************************************************************* + ** Validate scanner length limits and read optional display labels from server widget metadata with local defaults. + *******************************************************************************/ + fun parse(values: Map): ScannerMetaData { val suppliedLength = values["maxLength"] - val maxLength = if(!values.containsKey("maxLength")) 4096 else - { + val maxLength = if (!values.containsKey("maxLength")) 4096 else { require(suppliedLength is Number) { "SCANNER maxLength must be an integer between 1 and 4096." } val number = suppliedLength.toDouble() require(number.isFinite() && number in 1.0..4096.0 && number == number.toInt().toDouble()) @@ -18,10 +22,10 @@ internal data class ScannerMetaData(val manualLabel: String = "Manual barcode", number.toInt() } return ScannerMetaData( - manualLabel = (values["manualLabel"] as? String)?.takeIf { it.isNotBlank() } ?: "Manual barcode", - submitLabel = (values["submitLabel"] as? String)?.takeIf { it.isNotBlank() } ?: "Submit barcode", - maxLength = maxLength, - ) + manualLabel = (values["manualLabel"] as? String)?.takeIf { it.isNotBlank() } ?: "Manual barcode", + submitLabel = (values["submitLabel"] as? String)?.takeIf { it.isNotBlank() } ?: "Submit barcode", + maxLength = maxLength, + ) } } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt index ebea16c..e7692ef 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt @@ -35,11 +35,14 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBloc import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessAcknowledgment import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed -/** A server-defined SCANNER widget accepts device-owned callbacks only while visible and resumed. */ +/******************************************************************************* + ** A server-defined SCANNER widget accepts device-owned callbacks only while visible and resumed. + *******************************************************************************/ @Composable -internal fun ScannerWidget(parameters: WidgetBlockParameters, modifier: Modifier, disabled: Boolean, - driverFactory: (Context) -> ScannerDriver? = ::createScannerDriver) -{ +internal fun ScannerWidget( + parameters: WidgetBlockParameters, modifier: Modifier, disabled: Boolean, + driverFactory: (Context) -> ScannerDriver? = ::createScannerDriver +) { val context = LocalContext.current val view = LocalView.current val windowFocused = LocalWindowInfo.current.isWindowFocused @@ -53,55 +56,66 @@ internal fun ScannerWidget(parameters: WidgetBlockParameters, modifier: Modifier var canReconnect by remember { mutableStateOf(false) } val currentDisabled by rememberUpdatedState(disabled) val metadataResult = runCatching { ScannerMetaData.parse(parameters.widgetBlock.values) } - if(metadataResult.isFailure) - { + if (metadataResult.isFailure) { Text("This scanner control has unsupported metadata. Contact your administrator.", modifier.padding(20.dp)) return } val scannerMetaData = metadataResult.getOrThrow() val process = parameters.qViewModel?.activeProcessViewModel + + /******************************************************************************* + ** Bind input eligibility to the same active process and loaded server step that rendered this widget. + *******************************************************************************/ fun processReady() = process != null && parameters.qViewModel?.activeProcessViewModel === process && !currentDisabled && !process.cancelling && !process.waitingOnBackend && process.pendingStepValues == null + + /******************************************************************************* + ** Combine operator focus, lifecycle and process readiness before accepting hardware or manual input. + *******************************************************************************/ fun inputReady() = inputAllowed() && view.hasWindowFocus() && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) && processReady() + val ready = windowFocused && inputAllowed() && processReady() val currentInputReady by rememberUpdatedState<() -> Boolean>(::inputReady) LaunchedEffect(parameters.values["acknowledgedEventId"]) { notice = "" } - /** Each new physical scan has an identity; retries are handled by ProcessViewModel. */ - fun submit(barcode: String, symbology: String, source: String): Boolean - { - if(process == null) - { + /******************************************************************************* + ** Each new physical scan has an identity; retries are handled by ProcessViewModel. + *******************************************************************************/ + fun submit(barcode: String, symbology: String, source: String): Boolean { + if (process == null) { notice = "This scanner control requires an active process." return false } - if(!inputReady()) - { + if (!inputReady()) { notice = "Wait for the previous scan to be confirmed." return false } - if(barcode.isEmpty() || barcode.length > scannerMetaData.maxLength) - { + if (barcode.isEmpty() || barcode.length > scannerMetaData.maxLength) { notice = "Scan must contain between 1 and ${scannerMetaData.maxLength} characters." return false } notice = "Sending scan…" val event = ScanEvent(barcode, symbology, source) - process.runStep(event.values(), ProcessAcknowledgment("acknowledgedEventId", event.eventId, - requiredValues = mapOf("scanSaved" to true), nonEmptyStringFields = setOf("scanId"))) + process.runStep( + event.values(), ProcessAcknowledgment( + "acknowledgedEventId", event.eventId, + requiredValues = mapOf("scanSaved" to true), nonEmptyStringFields = setOf("scanId") + ) + ) // runStep can reject local metadata/validation before accepting an event. // A synchronous test repository can also finish and acknowledge it immediately. val accepted = process.pendingStepValues?.get("eventId") == event.eventId || process.processValues["acknowledgedEventId"] == event.eventId - if(!accepted) notice = "Scan was not submitted. Review the process message and try again." + if (!accepted) notice = "Scan was not submitted. Review the process message and try again." return accepted } val currentSubmit by rememberUpdatedState<(String, String, String) -> Boolean>(::submit) val input = remember(context, process, driverFactory) { - ScannerInputSession({ driverFactory(context) }, + ScannerInputSession( + { driverFactory(context) }, { barcode, symbology -> currentSubmit(barcode, symbology, HARDWARE_SCAN_SOURCE) }, { message, reconnect -> scannerStatus = message; canReconnect = reconnect }, canAcceptInput = { currentInputReady() }) @@ -109,7 +123,13 @@ internal fun ScannerWidget(parameters: WidgetBlockParameters, modifier: Modifier SideEffect { input.setAccepting(ready) } DisposableEffect(input, lifecycle) { - fun syncRegistration() { input.setActive(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) } + /******************************************************************************* + ** Let the current lifecycle decide hardware ownership rather than retaining capture after pause. + *******************************************************************************/ + fun syncRegistration() { + input.setActive(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) + } + val observer = LifecycleEventObserver { _, _ -> syncRegistration() } lifecycle.addObserver(observer) syncRegistration() @@ -120,20 +140,22 @@ internal fun ScannerWidget(parameters: WidgetBlockParameters, modifier: Modifier { Text(parameters.widgetBlock.values["title"]?.toString() ?: "Scan a barcode") Text(parameters.widgetBlock.values["instructions"]?.toString() ?: "Press the handheld scan trigger. Wait for server confirmation before scanning again.") - if(parameters.values["scanSaved"] == true && notice.isEmpty()) - { + if (parameters.values["scanSaved"] == true && notice.isEmpty()) { Text("Saved scan ${parameters.values["scanId"] ?: ""}") } - if(notice.isNotEmpty()) Text(notice) - if(scannerStatus.isNotEmpty()) Text(scannerStatus) - if(canReconnect) Button(enabled = ready, + if (notice.isNotEmpty()) Text(notice) + if (scannerStatus.isNotEmpty()) Text(scannerStatus) + if (canReconnect) Button( + enabled = ready, onClick = { input.reconnect() }) { Text("Reconnect scanner") } - OutlinedTextField(manual, { manual = it }, label = { Text(scannerMetaData.manualLabel) }, enabled = ready, singleLine = true, + OutlinedTextField( + manual, { manual = it }, label = { Text(scannerMetaData.manualLabel) }, enabled = ready, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboard?.hide() })) + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); keyboard?.hide() }) + ) Button(enabled = ready && manual.isNotEmpty(), onClick = { focusManager.clearFocus(); keyboard?.hide() - if(submit(manual, "MANUAL", "manual")) manual = "" + if (submit(manual, "MANUAL", "manual")) manual = "" }) { Text(scannerMetaData.submitLabel) } } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScreenConsentAttempt.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScreenConsentAttempt.kt index b9b208f..e5d6db2 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScreenConsentAttempt.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScreenConsentAttempt.kt @@ -1,18 +1,37 @@ /* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package io.qrun.mobile.scanner -/** Single-use, memory-only binding for the Android result and subsequent service connection. */ -internal class ScreenConsentAttempt -{ +/******************************************************************************* + ** Single-use, memory-only binding for the Android result and subsequent service connection. + *******************************************************************************/ +internal class ScreenConsentAttempt { private var generation: String? = null private var pending = false - fun begin(): String { val ticket = java.util.UUID.randomUUID().toString(); generation = ticket; pending = true; return ticket } - fun invalidate() { generation = null; pending = false } + /******************************************************************************* + ** Issue a fresh memory-only ticket so a result from an earlier consent attempt cannot activate sharing. + *******************************************************************************/ + fun begin(): String { + val ticket = java.util.UUID.randomUUID().toString(); generation = ticket; pending = true; return ticket + } + + /******************************************************************************* + ** Revoke pending consent before host shutdown or a new sharing attempt. + *******************************************************************************/ + fun invalidate() { + generation = null; pending = false + } + + /******************************************************************************* + ** Check ticket identity independently of consumption for subsequent asynchronous service callbacks. + *******************************************************************************/ fun current(ticket: String) = ticket == generation - fun consume(ticket: String, granted: Boolean, authenticatedAndApproved: Boolean): Boolean - { - if(!current(ticket) || !pending) return false + + /******************************************************************************* + ** Accept a consent result at most once and only while its host remains authenticated and approved. + *******************************************************************************/ + fun consume(ticket: String, granted: Boolean, authenticatedAndApproved: Boolean): Boolean { + if (!current(ticket) || !pending) return false pending = false return granted && authenticatedAndApproved } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionHostUi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionHostUi.kt index 89c08b9..5b643f9 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionHostUi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionHostUi.kt @@ -16,38 +16,45 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -internal fun sessionScreen(model: QViewModel, foreground: Boolean, focused: Boolean, approved: Boolean, laidOut: Boolean): SessionScreen -{ +/******************************************************************************* + ** Derive capture eligibility from actual QQQ state and password metadata without transmitting field values. + *******************************************************************************/ +internal fun sessionScreen(model: QViewModel, foreground: Boolean, focused: Boolean, approved: Boolean, laidOut: Boolean): SessionScreen { val process = model.activeProcessViewModel val step = process?.activeStep val hasPassword = (step?.formFields.orEmpty() + step?.viewFields.orEmpty()).any { !it.isHidden && it.type == QFieldType.PASSWORD } || containsPasswordWidget(step?.components?.map { it.values }) || containsPasswordWidget(process?.processValues) - return SessionScreen(foreground, model.isAuthenticated && !model.isLogoutRequested && model.sessionUUID.isNotBlank(), + return SessionScreen( + foreground, model.isAuthenticated && !model.isLogoutRequested && model.sessionUUID.isNotBlank(), focused, approved, hasPassword, laidOut && model.qInstanceLoadState is LoadState.Success && (process == null || step != null || process.getNoMoreSteps()), false, model.qNavigator.navigationRevision, process?.stepInstanceCounter ?: 0, model.qNavigator.currentAppName, process?.processName, process?.processUUID, process?.activeStepName?.ifEmpty { null }, - (step?.label ?: model.qNavigator.titleStack.lastOrNull() ?: "Apps").take(200)) + (step?.label ?: model.qNavigator.titleStack.lastOrNull() ?: "Apps").take(200) + ) } -/** Widget field metadata is nested in composite blocks; inspect types without transmitting any values. */ -private fun containsPasswordWidget(value: Any?): Boolean = when(value) -{ +/******************************************************************************* + ** Widget field metadata is nested in composite blocks; inspect types without transmitting any values. + *******************************************************************************/ +private fun containsPasswordWidget(value: Any?): Boolean = when (value) { is Map<*, *> -> (value["fieldMetaData"] as? Map<*, *>)?.get("type") == "PASSWORD" || value.values.any(::containsPasswordWidget) is List<*> -> value.any(::containsPasswordWidget) else -> false } +/******************************************************************************* + ** Keep the operator informed of active viewing and expose immediate pause/resume control. + *******************************************************************************/ @Composable -internal fun ScreenViewingIndicator(controller: MobileSessionController) -{ - if(controller.viewing || controller.operatorPaused) Surface(color = MaterialTheme.colorScheme.secondaryContainer) +internal fun ScreenViewingIndicator(controller: MobileSessionController) { + if (controller.viewing || controller.operatorPaused) Surface(color = MaterialTheme.colorScheme.secondaryContainer) { Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { - Text(if(controller.operatorPaused) "Screen viewing paused" else "Screen being viewed by an administrator", Modifier.weight(1f)) - TextButton(onClick = { if(controller.operatorPaused) controller.allowViewing() else controller.pauseViewing() }) - { Text(if(controller.operatorPaused) "Allow viewing" else "Stop viewing") } + Text(if (controller.operatorPaused) "Screen viewing paused" else "Screen being viewed by an administrator", Modifier.weight(1f)) + TextButton(onClick = { if (controller.operatorPaused) controller.allowViewing() else controller.pauseViewing() }) + { Text(if (controller.operatorPaused) "Allow viewing" else "Stop viewing") } } } } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionScreen.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionScreen.kt index 4c2317c..5004d9a 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionScreen.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/SessionScreen.kt @@ -4,9 +4,14 @@ package io.qrun.mobile.scanner import java.time.Instant import org.json.JSONObject +/******************************************************************************* + ** Distinguish application-only pixels from separately consented device projection in every lease and frame. + *******************************************************************************/ internal enum class CaptureMode { APP_CONTENT, DEVICE_SCREEN } -/** Metadata only. Authentication credentials and field/scan values never enter telemetry. */ +/******************************************************************************* + ** Metadata only. Authentication credentials and field/scan values never enter telemetry. + *******************************************************************************/ internal data class SessionScreen( val foreground: Boolean, val authenticated: Boolean, @@ -24,58 +29,81 @@ internal data class SessionScreen( val label: String? = null, val location: LocationReport = LocationReport(), val captureMode: CaptureMode = CaptureMode.APP_CONTENT, -) -{ - val blockedReason: String? get() = when - { - !authenticated -> "SIGNED_OUT" - !approved -> "DEVICE_NOT_APPROVED" - !foreground && captureMode == CaptureMode.APP_CONTENT -> "APP_BACKGROUND" - !focused && captureMode == CaptureMode.APP_CONTENT -> "APP_NOT_FOCUSED" - paused -> "OPERATOR_PAUSED" - hasPassword -> "PASSWORD_SCREEN" - !ready -> "SCREEN_LOADING" - else -> null - } +) { + val blockedReason: String? + get() = when { + !authenticated -> "SIGNED_OUT" + !approved -> "DEVICE_NOT_APPROVED" + !foreground && captureMode == CaptureMode.APP_CONTENT -> "APP_BACKGROUND" + !focused && captureMode == CaptureMode.APP_CONTENT -> "APP_NOT_FOCUSED" + paused -> "OPERATOR_PAUSED" + hasPassword -> "PASSWORD_SCREEN" + !ready -> "SCREEN_LOADING" + else -> null + } val available: Boolean get() = blockedReason == null } -internal data class ScreenFrame(val jpeg: ByteArray, val width: Int, val height: Int, val capturedAt: String, - val captureMode: CaptureMode = CaptureMode.APP_CONTENT) +/******************************************************************************* + ** Carry one bounded encoded capture with its dimensions, timestamp and consent mode. + *******************************************************************************/ +internal data class ScreenFrame( + val jpeg: ByteArray, val width: Int, val height: Int, val capturedAt: String, + val captureMode: CaptureMode = CaptureMode.APP_CONTENT +) -internal data class ScreenLease(val id: String, val durationMillis: Long, val intervalMillis: Long, +/******************************************************************************* + ** Carry server-issued capture limits and sequence continuity for the current authenticated viewer. + *******************************************************************************/ +internal data class ScreenLease( + val id: String, val durationMillis: Long, val intervalMillis: Long, val maxWidth: Int, val maxHeight: Int, val maxBytes: Int, val nextSequence: Long, - val captureMode: CaptureMode = CaptureMode.APP_CONTENT) + val captureMode: CaptureMode = CaptureMode.APP_CONTENT +) -internal data class SessionHeartbeat(val sessionId: String, val intervalMillis: Long, val lease: ScreenLease?, - val supportedCaptureModes: Set = setOf(CaptureMode.APP_CONTENT)) -{ - companion object - { - fun parse(payload: String): SessionHeartbeat - { +/******************************************************************************* + ** Represent the authoritative session and optional bounded viewing lease returned by a heartbeat. + *******************************************************************************/ +internal data class SessionHeartbeat( + val sessionId: String, val intervalMillis: Long, val lease: ScreenLease?, + val supportedCaptureModes: Set = setOf(CaptureMode.APP_CONTENT) +) { + /******************************************************************************* + ** Parse server heartbeat limits at the boundary before they govern capture scheduling. + *******************************************************************************/ + companion object { + /******************************************************************************* + ** Require an active server session and clamp advertised timing and size limits before any capture. + *******************************************************************************/ + fun parse(payload: String): SessionHeartbeat { val value = JSONObject(payload) require(value.getString("status") == "ACTIVE") { "Inactive mobile session." } val serverTime = Instant.parse(value.getString("serverTime")) val lease = value.optJSONObject("screenMirror")?.let { - ScreenLease(it.getString("leaseId"), + ScreenLease( + it.getString("leaseId"), (Instant.parse(it.getString("expiresAt")).toEpochMilli() - serverTime.toEpochMilli()).coerceIn(0, 30000), it.getLong("frameIntervalMillis").coerceIn(1000, 30000), it.getInt("maxWidth").coerceIn(1, 640), it.getInt("maxHeight").coerceIn(1, 1280), it.getInt("maxBytes").coerceIn(1, 262144), it.getLong("nextSequence").coerceAtLeast(0), - CaptureMode.valueOf(it.optString("captureMode", "APP_CONTENT"))) + CaptureMode.valueOf(it.optString("captureMode", "APP_CONTENT")) + ) } val advertised = value.optJSONArray("supportedCaptureModes") - val modes = if(advertised == null) setOf(CaptureMode.APP_CONTENT) else + val modes = if (advertised == null) setOf(CaptureMode.APP_CONTENT) else (0 until advertised.length()).mapNotNull { index -> CaptureMode.entries.find { it.name == advertised.optString(index) } }.toSet() return SessionHeartbeat(value.getString("sessionId"), value.getLong("heartbeatIntervalMillis").coerceIn(1000, 15000), lease, modes) } } } -/** A frame is usable only for the exact state and session generation in which capture began. */ -internal data class ScreenTicket(val generation: Long, val state: SessionScreen, val leaseId: String) -{ +/******************************************************************************* + ** A frame is usable only for the exact state and session generation in which capture began. + *******************************************************************************/ +internal data class ScreenTicket(val generation: Long, val state: SessionScreen, val leaseId: String) { + /******************************************************************************* + ** Reject completed capture when host state, generation, lease identity or monotonic expiry has changed. + *******************************************************************************/ fun valid(generation: Long, state: SessionScreen, leaseId: String?, now: Long, expiresAt: Long) = this.generation == generation && this.state == state && state.available && this.leaseId == leaseId && now < expiresAt } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ZebraScannerDriver.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ZebraScannerDriver.kt index f146e07..9db3214 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ZebraScannerDriver.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ZebraScannerDriver.kt @@ -10,14 +10,18 @@ import com.symbol.emdk.barcode.BarcodeManager import com.symbol.emdk.barcode.Scanner import com.symbol.emdk.barcode.StatusData -/** Loaded only after checking Zebra's device-provided library and preinstalled service. */ -internal class ZebraScannerDriver(private val context: Context) : ScannerDriver -{ - companion object - { +/******************************************************************************* + ** Loaded only after checking Zebra's device-provided library and preinstalled service. + *******************************************************************************/ +internal class ZebraScannerDriver(private val context: Context) : ScannerDriver { + /******************************************************************************* + ** Serialize the application-wide EMDK manager claim across asynchronous driver lifetimes. + *******************************************************************************/ + companion object { // EMDK supports one manager per application, including a bind that has not completed yet. private var managerClaimed = false } + private val main = Handler(Looper.getMainLooper()) private var listener: ScannerDriver.Listener? = null private var manager: EMDKManager? = null @@ -27,17 +31,15 @@ internal class ZebraScannerDriver(private val context: Context) : ScannerDriver private var ownsClaim = false private val dataListener = Scanner.DataListener { collection -> main.post { - if(!closed) - { + if (!closed) { val scans = collection?.scanData - if(collection?.result != com.symbol.emdk.barcode.ScannerResults.SUCCESS || scans.isNullOrEmpty()) + if (collection?.result != com.symbol.emdk.barcode.ScannerResults.SUCCESS || scans.isNullOrEmpty()) listener?.noData(false) - else if(scans.size != 1) listener?.noData(true) - else - { + else if (scans.size != 1) listener?.noData(true) + else { val scan = scans.single() val barcode = scan?.data - if(barcode.isNullOrEmpty()) listener?.noData(false) + if (barcode.isNullOrEmpty()) listener?.noData(false) else listener?.scanned(barcode, scan.labelType?.toString() ?: "UNKNOWN") } } @@ -45,80 +47,103 @@ internal class ZebraScannerDriver(private val context: Context) : ScannerDriver } private val statusListener = Scanner.StatusListener { status -> main.post { - if(!closed) - { - if(status.state == StatusData.ScannerStates.IDLE) listener?.idle() - else if(status.state == StatusData.ScannerStates.ERROR) listener?.failed() + if (!closed) { + if (status.state == StatusData.ScannerStates.IDLE) listener?.idle() + else if (status.state == StatusData.ScannerStates.ERROR) listener?.failed() } } } - override fun open(listener: ScannerDriver.Listener) - { + /******************************************************************************* + ** Claim the single EMDK manager before asynchronous binding and report unavailable ownership explicitly. + *******************************************************************************/ + override fun open(listener: ScannerDriver.Listener) { check(!closed && this.listener == null) this.listener = listener - if(managerClaimed) { listener.failed(); return } + if (managerClaimed) { + listener.failed(); return + } managerClaimed = true ownsClaim = true opening = true - val result = try { EMDKManager.getEMDKManager(context, object : EMDKManager.EMDKListener - { - override fun onOpened(opened: EMDKManager) - { - main.post { - opening = false - if(closed) - { - // The asynchronous bind can complete after the widget has already left. - try { opened.release(); releaseClaim() } - catch(exception: Exception) { android.util.Log.w("Scanner", "Late scanner release could not be confirmed") } - } - else - { - manager = opened - try - { - val barcodeManager = opened.getInstance(EMDKManager.FEATURE_TYPE.BARCODE) as BarcodeManager - val device = barcodeManager.getDevice(BarcodeManager.DeviceIdentifier.DEFAULT) - scanner = device - device.addDataListener(dataListener) - device.addStatusListener(statusListener) - device.triggerType = Scanner.TriggerType.HARD - device.enable() + val result = try { + EMDKManager.getEMDKManager(context, object : EMDKManager.EMDKListener { + /******************************************************************************* + ** Release a late EMDK bind after closure; otherwise attach scanner listeners only for the current owner. + *******************************************************************************/ + override fun onOpened(opened: EMDKManager) { + main.post { + opening = false + if (closed) { + // The asynchronous bind can complete after the widget has already left. + try { + opened.release(); releaseClaim() + } catch (exception: Exception) { + android.util.Log.w("Scanner", "Late scanner release could not be confirmed") + } + } else { + manager = opened + try { + val barcodeManager = opened.getInstance(EMDKManager.FEATURE_TYPE.BARCODE) as BarcodeManager + val device = barcodeManager.getDevice(BarcodeManager.DeviceIdentifier.DEFAULT) + scanner = device + device.addDataListener(dataListener) + device.addStatusListener(statusListener) + device.triggerType = Scanner.TriggerType.HARD + device.enable() + } catch (exception: Exception) { + this@ZebraScannerDriver.listener?.failed() + } } - catch(exception: Exception) { this@ZebraScannerDriver.listener?.failed() } } } - } - override fun onClosed() - { - main.post { - opening = false - if(closed) releaseClaim() else this@ZebraScannerDriver.listener?.failed() + + /******************************************************************************* + ** Resolve asynchronous manager loss on the main thread without reviving the released scanner. + *******************************************************************************/ + override fun onClosed() { + main.post { + opening = false + if (closed) releaseClaim() else this@ZebraScannerDriver.listener?.failed() + } } - } - }) } - catch(exception: Exception) - { + }) + } catch (exception: Exception) { opening = false releaseClaim() throw exception } - if(result.statusCode != EMDKResults.STATUS_CODE.SUCCESS) - { + if (result.statusCode != EMDKResults.STATUS_CODE.SUCCESS) { opening = false releaseClaim() listener.failed() } } + /******************************************************************************* + ** Read EMDK's pending state rather than inferring it from callback order. + *******************************************************************************/ override fun isReadPending(): Boolean = scanner?.isReadPending ?: false - override fun read() { requireNotNull(scanner).read() } - override fun cancelRead() { scanner?.let { if(it.isReadPending) it.cancelRead() } } - override fun close() - { - if(closed) return + /******************************************************************************* + ** Delegate a read only after the session has acquired and enabled the hardware scanner. + *******************************************************************************/ + override fun read() { + requireNotNull(scanner).read() + } + + /******************************************************************************* + ** Avoid cancelling an idle scanner while still stopping any active hardware read. + *******************************************************************************/ + override fun cancelRead() { + scanner?.let { if (it.isReadPending) it.cancelRead() } + } + + /******************************************************************************* + ** Detach every listener and release device/manager resources before relinquishing the application-wide claim. + *******************************************************************************/ + override fun close() { + if (closed) return closed = true listener = null val device = scanner @@ -126,25 +151,37 @@ internal class ZebraScannerDriver(private val context: Context) : ScannerDriver scanner = null manager = null var failure: Exception? = null - fun attempt(action: () -> Unit) { try { action() } catch(exception: Exception) { failure = exception } } - if(device != null) - { + + /******************************************************************************* + ** Attempt every resource release even when an earlier cleanup fails, then propagate the recorded failure. + *******************************************************************************/ + fun attempt(action: () -> Unit) { + try { + action() + } catch (exception: Exception) { + failure = exception + } + } + if (device != null) { attempt { device.removeDataListener(dataListener) } attempt { device.removeStatusListener(statusListener) } - attempt { if(device.isReadPending) device.cancelRead() } + attempt { if (device.isReadPending) device.cancelRead() } attempt { device.release() } } - if(owner != null) - { + if (owner != null) { attempt { owner.release(EMDKManager.FEATURE_TYPE.BARCODE) } attempt { owner.release() } } - if(!opening && failure == null) releaseClaim() + if (!opening && failure == null) releaseClaim() failure?.let { throw it } } - private fun releaseClaim() - { - if(ownsClaim) { ownsClaim = false; managerClaimed = false } + /******************************************************************************* + ** Relinquish only this driver's manager claim after asynchronous opening and cleanup are resolved. + *******************************************************************************/ + private fun releaseClaim() { + if (ownsClaim) { + ownsClaim = false; managerClaimed = false + } } } diff --git a/scanner-host/src/main/res/drawable-nodpi/query_mascot.png b/scanner-host/src/main/res/drawable-nodpi/query_mascot.png new file mode 100644 index 0000000000000000000000000000000000000000..84add487d5fd1b816959256a0eec31ff6e058b6a GIT binary patch literal 9539 zcmc(F_gfR)^L8K@q$3E@c~pw@1f(M0R1){rnN{`@<&JwL5!e&YYRK?>T$IpX;hok+YD4Kp-j&b!B}Jh)5f_EN(-9 zI}R%^t$;r=S9Mbl5Qu{A??p5=7kCK*{R7fae)iHQeS2=(bEb9%vPUtz>0Lx0lJd_E zi52fB*=Gr4zqDy>-^ln?T|tzhG3_Bl$@E!K>S^+C1LI1=rq^ zJ>YJ>10}#|?~GdABsUQ&D;s>nb3GvM{D1#*q_sm%n>fJaL6%MAO16-A>8oCMXzx-i6V7_9JCWk56QX1_B?t|t$?ho{Zpf$$7w!q&*~CvLGDj+3s7zNFdJ z1bOavuVldBcsq^8=}C3gIHlH(&~^5XYI92MREAA=5!fql*|1%Z5n;M%mLVq#26bf2 zp9ugj9Ft|Wb{#U=h|mmrS^xX(Pb|B~?|*IcQK5!Ox;`F+uS zegC9K$xPTybM=zdm8_aFXf2N)OyXr9{t z2iFH~*RfxBp335Oojx;RA3Jto8N#}+2_Qzow6I3#gILK>*0XL~=2SA^tvKRt0p6)E zM`UN2%&D<}N8GujWX>kVj9Mdw5!SQ(_J`L#5>> z7PFaQUp{u^h6$4P*xk2G(5hICy3axej{nSEC;Nv-oGD4632ml}i$+U7fbcMA@c8=6 zMk%z<+3P5Fxfu6xL)Jb4?)}1W6#2MzOVD2O8~xLnh7H9XwW2$KTct#ui|4NEh+O}x z{N8J_mgioqV;27o1}%Ohy1X^B9GkYrQAmX}=c!OB07gk=IAZs8MzzklDQD<+;n~P0 zt@L3K=VLQ)y^XOP2BvyZaGj{ocK9_^L*<8bjkZ{tn-+4Q09Gj0W#TKSdnSr1z?2RA z)q7WK!(v}VjJ2-0W4Ng#NcR3?zk<>yU*&56Cr$M#4`=lih+mcs{pzuhp4R`udd?g= z_7m}=os9?_uUUDJFzlp6W%;iK9*0*tR`Yj~AqK}+#i>k86Isp|1yT=_O`}nqQ4Y4R zg$5=?b+zpg2z_TQJ!zK!|6;03sd=o3S&F?iLSF)0)-U2d**AMcT3&bGQaKD(lX8$f z%&P$5S*>~5CxIPR9?rbRAFq9?nBcR0W0e_>)-a{45q7cGIqG5Qw*8j&;_bsgp>4+A zQ?;SmagLvxN~TOmnP$oXs$d>fgywU(1>dsuC*;kNb4-UM0Z;f{SMD@sRvgd4`-SFp zI~jSnd0D}{P8Lh6?PuN~&2(`NmMAHSS!2y9Kk0)Hx_Cf9(p6P2;Ua@{sM*(GdL>Tq zYBQ%!G)Td&IC1RDPfr?zbVq@iy%j=VpYHYzUve(Hv=ycSvNsvnfFpGJw0dYz0RDaE zJvQ9UR86wyyP(9esEA8e$O6d?{0>7SD77{F?!Ef-V_K|BZejIKi%RsW#+ECYq*$PR z*%RN@;PnfduU&@&R}$KOzH*5Y*J*&C<3ziTe0jR>qlX!Vhh>S=EYtUeF6{^O$ieaYqwwS{>7g3kZ$m3O>laMS z@y-kHkI1hz3SRG6*(ay#j}nU+m*fo}H5+~<$eW;cOL1DLZSG)8BL7~C3M>=$u7%Z{ z8@`M;KeksHUSw`f5x;S}Oh2hw_SK-sp7NOleF_^ehv)j<=^Hkb+MhL@_s4Y=Z4g7< z0XyWFP_)KZ$FhE%cC(F|eNC_4O)fyB%&J0JVhv14#*yw4V@DJnCotS-SM{Ioi9bet`({Es|BY&16in3GVu^;?A;vcR z%WBiRU-a0F=edW^9UPh~xmXC(Pd+4r@cfq@Y1t)55Kka1Ss^mjjhOw7gv%asW~5UA z%gwjwOd|vpS-DLm^Zw|8QD%StTXBsDv<9rEK_EN!KGF7XC&=2qb9S`B{N!{a2@^I! zm#<&TNRt_ScpE^LV4A)Hs>>}{34oI?H8v>-E{VcrXc@`dqu-}09- z3uHkKlz^>#%{+|#|BnB-LBJKS$=9^>z$;@Q%zN+Ssy9&iO+!Vk5o2t{)vE}B!&}G! zI$=Q^83!ImxEKB$w+gcyPfVm|~ zc;C9~?Zb?Xp9&4&C8>k2?zZMdMSl%L85*6cx(fn4#q?uxnwpIxv(}l=z6YpP(L_=@ zNj!MfylI~0@6yaojOUkk-X}~CF&*a4GI^v}2{g)5>Q&`ep-cMv;4`-6T5doDe~|Ei zD>6CMxD7zaTvihZwnJINEW%u}UQHhXgCXK?Ffb={SrX~SGPd`92mAtonOeB~+C>}f zDm$;xlb1F#mhA!H{aeB`uO8?z^A`{GiUjfSEs)&s5a;-qe`i0zzwYpqSFX+X36yEL z8=r5&vFA=)pyEr4Jk~Ugucw}AkzrlvW}cssF<)MN|3`e5T_PZ^ZuJfWB_Mpl>}Q32^8H-i?;i=5YFrji6x@`_}~gcR04-yA$vzp?%CCFuscJb0|=C z{25b`SZ2V*GN$pqCFbu0is*QF)qV*lXw8;=YzBn;=>A~N?UCA1|NWjEX<9DnJkBNq zz=&>a(BhqNg?{;90`og3$8ls>Fi^Up;K|wn(K2Bi6 z;*cxH5o7vHfpX=ORMv6Wb)nj07#RBtjU6ISdd0VkRsSxqM+H>(Dqbg;@62{2X|w{ z5Maunt^@Tp{*XvmY8L~Gh}nDGWzK?hUf3bKp}bkNt|LtUcgIlpS$q?*N0(T@BLQjH z*U*`l?UUprvkelvM0lp^Fp7?Cu53B#gbCco!$9 z*x@z_!5=Hzb#m?xO|ESTC|F>|_wb|n<25o3JT<5IKH^82 zeTrxzdHxYJla5H&MPNn)O@o$)AG6m&9q*F1)42+1U0Y|Zzu-d|7+V8@_qH~;^0H@D zDR1L{b;B+atzl0_*>6}yK2c`SCuNXH3y#<2k)`oOm=w0(`kexUZgRPk4$mV@cpg&x zz=;J;*NCTxKzP`<@!L_L4$(qgJ{0|=RV^@b0@$hnZMIgWX_RxmsLJ*?aj|Sdu)9g- z_RN+=2`!0(DSV<3o?Ee3f;a;XqA*+012KZck^{Z5Es*(dV?qjC6#CVmf(FPsIeG{W zm9+_Du|eN-rze*CrmK#>L+)#YNs=RCsYt^-(bNht^nHkj zMQMF^mb>gOd&DvpSUvkki0=D8Tg0d+2RfOsVjY#GpHFVS1Mrd#h1!Q$nP$A=I~1bG zyR-M|AO&GU==CrgRGo)|Hjt@t zQ4aihyI1j_8C0-je7zhGSRwF$G>K~4zSplqVA;&2*CJvGfiP-kst&n%tL8i^p-|y& zoUKYZK;B~^m-Qssg*jT++qM zJ1|sVuqI&N84l1Q-DvN)Zn$4Yqo}e+J3gHjIUo+{5*jCjIRr9c>F;j|-5i*=E^*Pk z@TVNuH1X|ggQJ>4jbRb^xugqlKR_TDbsLwa>2j%jcxj~N z;F5PBcIW=5;UWz`;~SVqO5i?RZ66n2kt zKvd|-s=1=_1-t+wG@#Ce4UJvqsR_?C6MoKnt7(Od8RfJiGeV8jj!sS^b^)2Y|Bmwl zkOOqXpB%VaR^?V1YD(LXr#AEKD$GiQyv(z~&ZMgYFBl^DaIEXmSue9}ZWm@#;Q*X0zS3fpE*>o{robtE6u; zjoRiksZjz2Zw4zd5$KeukssW{>p4Vlgco4FB;~+mIiA4HUSZhh|8aR#T`DXk#eR;v zTe!EM$>Y{SP#<@B8wQvbW~H3NX>L{2NyovuX6MX1%+~*nm7NV{bMHW+ClY5g^W198 zX1>eVXGc@w;0Nx?v~>RvLZ|0icj0vNdtIgh29>>zMgH8s0-7TaZlc^dG5K?qUsW^u z6yCgj+($-7F_V4k&GUXyRI^-aq}gesVGZytx`i`aIzWvz0MPU8PVQS9E17Eoz78(L ze5H$uNJ9^wcU{dcWw?kVTnZ<|4g#i_;5}?l-nF_PJx09<@0h!f>$8Z3Y4l&Ytd4OK z;I&Ztg?v4Z)&gaKGDxMu3S$ZTL7;0zUn{bD#vhVMZ4r7D&g79TD$G^}P>aC$;`_7t z-too0ZAu>bct_D`uh|&M8zld&Eoj**_qI=_piDu38>A9@5SR-#YY12rzTAAtjFpY2 zeli>KH0!I3LTqnlP=Z#L<`XjgU$f6#7%t@c*5BCUnqs#+$WDW1ANcEUGUAW#CeKr1 zd_A^p6%B6{{^l*7OTM~2IcKpN=*En623;1L^PhyeU>Z7p3gWE6lAB{h^;+jG>cPSb zVKUUA>n!+*1fDPmJUO2Euv|qPE$w>RQBE~mm62w?xRSDZ>0EX!bgy@aN$luB!$&_c zO_n(M1DU;y^;R5uD_76@cSRusgMsmQ{_ukZq%g=IGSV+%q<@eOr*Hte*X}>L33KgN z?X`sI-$JLr=1~T&_ST-PIZEGyXd%b|OB+WhU`(24PTDjaZD-r4Vmp|Cr~T$q)zh+~ z^FHYrK7%-$`-rO9^*{&L(KLL2s8i5{)m?+k&eSh(;zJha@P59X#jcv%x)=;h54$(r zUmT}_mp$Vx`%IaICTsU|R57r|z3nx2UDlUJfjFOV9#TK$rTCf`yHkHcAsg;i!xs;I zv&eJv*n14%KQ0p_!$rgSpYaKV#gT{&@!5kUNV!VGuFz8S!&=QLJAQf^1K#Q-fWtLE zwQ?~(fNaTT1&WGY;))+YiJo@lTnD!mOc6Qc3wV^62Z=0Oe07M|R=J8J=>9aR$Zk{p zsd{}w!_&fUx#gZx$zM~HiYJ++LJFY2j>3Z(4MUU9-jmX+3w z*4hn*4mT;DJ0XY_Ab)U`?@*X2=bHi{=T}kY3{HEktmo1jKCR*HOLjS0uLNXx4M}j~0A6UE(B5ePgb zA~v(hu#dpjEYbxul09x)r6Wm{?0%i3Dxwkigs+@sDs?dm#(lWcheCG1#)@-Dh1SCu zj!!3HQ-x){G%ZF<7hUA!ViEYe0tLUznrU$;yj0s`wl=}66FZqd2UWD-C((k(Iaz}| zP|_WS)`WYT8)X>uzLurMBcNsWU|)l5cs~>e`WF#EBD5BhpQwPW5v>`XcfG6ttNE~T6&sp4=b&2 zc^D(q{@0v6{oHV?FxiC^i+1awoxW2u$7~tBmSfzfDL3TfwZ@3e&?o%93~ zG#Y)(_EKm*%{#u9rN+e>UF_&}Ex&g5fY*P63w(-^kr^7AC9XE?vkqml@8MN+e&yrlufFCHSR?8Fb31-&mRO8?`@g~MhQ9Cowt12E zS?YcsP6o#B1H15;k9EqrKh}=X0)^OpwNyYiY+w!+voi#I##W(c~lL|;Doy6 zLRY@;s-qQ`#+@1P#JlQcQT=fhxDIuoqmcJkz*_ckIkpL<>IQ6s=-hX4o|fWoT4e^D z8X%Sv^YloFTz;R8VH@Sxul%SjRNtu2`7Hg6Z{Q6vedd0D#?v zZM#-YGx~~Gd`Jqhyav*>EA-aYr{Ur90AEJUsm7QcsqD6E09Zz6!PWEUO(#Qh0=D;A z3NR=r#RGbeX|8#lr7x7DKW);!CuZAw&w2?|SO&?_ z3gt=PTFWVTIEda_x2CA374<4^;c(VXUx~0r^gr|J7fVq=P?tu`4#l*+za9$sC8GCx z|GCU4-t>qyVS;^rjE6&dKfYIxnEsvj#Yjl>DZ6}dZLCtz(qyiiP)?gP<3!`^=>OPP z3|0%lA;Z5kLMdbTw6Q9PKbb(7wq5!a0xQ{144r<(I=k%4ndE9@?0t%O%SonxwocD?PwHmS2B z&mV0P~d? z>CU)c*`m6aceDHOl;%NX7(c;V+>EqIS#-0!KX| z!Wsv$16W$q&7RQB0;TViR}%Gk`+>Zh3GutS%PXwjg6tnj#mOwl%X?Vx`r%u@8$zSl zG!N;^Pr;R#TK^4@XZp(>DVBJ`v5RUTHOq4ggrbW@*p7TB!8XT6wwn67FHH0p66tg1AwvefNrBhE){I5NstzxHngcWA9 za^C5N_%7aKlIUngY=v5@VU=;;^qn?&_UA~1gr;dxLV@{*-jq{dyneQ3?%Uf@xXFEp|D&DZ5)tv359VB1Ot z{ha*;f0XV~r6E^4!R256Oa0yn(e*z+@H#uCafbZq1fd{FLb+nAnOsB7ql_=a^vk=M zfeC1CoxAwIpTjuMc)dA@%o&@BT-W(r)cmQ-X67+x$BrPdhxzN%{<;lpdd7ZV8$|xM zta*Qxo_;aCx}NHc;Ulj)z{;)hU}=&y*xdff!1`n$tez+SgT#-JsJq>Kcr4f;}JTP4_^n;|6B{7V`0?-dP5 zX=-WANUznd!0OnDn--!$sqcBcsl%s)b`wQ)AM;q3;u^M~EJ)1B;H7-!c*My*XSCyl zI_Pq_;W7+;uKm-*dBMf7t4j5U4w;^-lGL0uy=T>C*G+;}UNeVWg_%=T@rR36*;eZA z7Xb)3Y7pP;?K36nUqgU+4$_d|lfxrhRO~UEunfWo#2LG~mQcyOJm|B4qA4>t_6_kX z`>A7dP^yme?6s5{`R{~X1nToC5%X>Cn*!vtr}8qTrm>JNDflAjiXPwAg`FUa7;Y1= zg*B~yY!3BnIRR2LdgF=CF*>1&O2qi-G7;1yD9+6H6)bG@toqO{aCe}lZ6~0#@p_1 z4r6A+pR9p}&rar_B@<^(XMGaw5{|_0~zMQkKo!yEd(QBz2S<#eyVi8@JlzAzq53A>fEv zH{vCOQPN(dJbM0Vw`#%-o0`)g8gJzW4c$1ryRa2f@1@cN%A5N<}p7?&3 z2%BopxT=b#=-CvkT02Qwuks!&EB$g-X?$t%UW!$(rM6kpZ@>azm}5UjPb2+XDY$Ls z-XBJ1_GghcP62z!@~1NDn>y$4)RMR2DM??54c=-_J*ti?6770iHFCv?9LRAK;ZF!z zGag<|ESjCPbr~)ln&NtlGvl|cMwOSqpaI}*r(d5z3h>06CEuJ2S+-2UY{g!i7>U*^ zUB5_Ez+#o7cMHFAj{C~bSpte1@d-x3C&WFT2!bY`Uj#9n>8+?`wCy6FC7y5;FjxnFv}=gGd?5hv)9d3@il=WY=BR4~q`L7lC ze5oADw10Lyxij&X_{)t?wu5JzE+y+1%zKA^MV_F$u)rsC+-$?42z6HA4`;InM9W|P z(CvkF^r9ERIQcNH67Xi-+g%5_8V?bN#mKNaT< z9p|5zBNV>f@(Kz(-q5aCfLpg_s*Lo;WR4Sg1$^{7#7LL>kex#0-#(plNit))hBo16 z<3`}tXDWWBW}s(jWLl?wp-1*_4dhTLC;9{QLykzg@fJ^3ns}@e!db)hRYwrH%_p<^ za;=~h56kvYyWY0PcX$3Jm@MWrpjlZKG>B#GII(62OD%ET++j3P@Q#)d*?L$>^{bav zKEA#S(#7VaAFcdXJiR|G_I7;dSZw9=`tKA9B~_Cyp@td2sVI() +class DeviceScreenAcceptanceTest { + @get:Rule + val ui = createAndroidComposeRule() private val instrumentation = InstrumentationRegistry.getInstrumentation() private val context get() = instrumentation.targetContext private val client = OkHttpClient.Builder().callTimeout(10, TimeUnit.SECONDS).build() private lateinit var admin: SessionAcceptanceClient private lateinit var sessionId: String - @Test fun operatorConsentCapturesHomeAndStopsAtEverySessionBoundary() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun operatorConsentCapturesHomeAndStopsAtEverySessionBoundary() { check(context.packageName == "io.qrun.mobile.scanner.qa" && BuildConfig.BUILD_TYPE == "qa") check(InstrumentationRegistry.getArguments().getString("operatorConsent") == "true") check(Build.MANUFACTURER.startsWith("Zebra", true) && Build.MODEL.startsWith("TC22", true)) - check(context.getSystemService(PowerManager::class.java).isInteractive && - !context.getSystemService(KeyguardManager::class.java).isDeviceLocked) + check( + context.getSystemService(PowerManager::class.java).isInteractive && + !context.getSystemService(KeyguardManager::class.java).isDeviceLocked + ) val file = File(context.filesDir, "qa-screen.json") val config = JSONObject(file.readText()) check(file.delete()) check(config.getString("purpose") == "device-screen-qa") val server = URI(config.getString("serverUrl")) - check(server.scheme == "http" && server.host == "127.0.0.1" && server.port > 0 && - server.userInfo == null && server.query == null && server.fragment == null && server.path == "/") + check( + server.scheme == "http" && server.host == "127.0.0.1" && server.port > 0 && + server.userInfo == null && server.query == null && server.fragment == null && server.path == "/" + ) val origin = server.toString().trimEnd('/') comparatorControls() waitFor("QQQ server URL") @@ -77,22 +86,23 @@ class DeviceScreenAcceptanceTest waitFor("An administrator must approve this device before you can run mobile apps.") val device = DeviceCredentials(context, "$origin/") admin = SessionAcceptanceClient(origin, SessionAcceptanceClient.login(origin, config.getString("adminPassword"), client), client) - try - { + try { admin.apply("mobileDeviceApprove", JSONObject().put("deviceId", device.deviceId)) click("Check approval") await("approved-catalog") { ui.onAllNodesWithTag("appHome.rowForApp:mobileScanApp").fetchSemanticsNodes().isNotEmpty() } await("initial-session") { val current = admin.sessions().singleOrNull { it.optString("deviceId") == device.deviceId && it.optString("status") == "ACTIVE" } - if(current != null) sessionId = current.getString("id") + if (current != null) sessionId = current.getString("id") current != null && current.optString("captureMode") == "APP_CONTENT" } consent("initial") assertDeviceFrame() stage("screen-app-frame") - val launcher = instrumentation.context.packageManager.resolveActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), - PackageManager.MATCH_DEFAULT_ONLY)?.activityInfo?.packageName + val launcher = instrumentation.context.packageManager.resolveActivity( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), + PackageManager.MATCH_DEFAULT_ONLY + )?.activityInfo?.packageName assertTrue("An unambiguous Android Home launcher must be available", !launcher.isNullOrBlank() && launcher != context.packageName) stage("screen-home-ready") await("operator-Home-and-matching-frame", 120) { @@ -138,17 +148,19 @@ class DeviceScreenAcceptanceTest fresh != null && fresh.getString("id") != sessionId && fresh.optString("captureMode") == "APP_CONTENT" && !projectionNotification() } stage("screen-fresh-login-verified") + } finally { + admin.logout() } - finally { admin.logout() } } - private fun consent(round: String) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun consent(round: String) { assertFalse("Each round must begin without a running projection", projectionNotification()) ui.onNodeWithTag("topBar.menuIcon").performClick() ui.onNodeWithTag("drawer.settings").performClick() - if(Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) - { + if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { waitFor("Enable sharing notification") stage("screen-notification-permission-ready") click("Enable sharing notification") // Own app requests permission; the operator grants/denies Android's dialog. @@ -170,8 +182,10 @@ class DeviceScreenAcceptanceTest click("Close") } - private fun assertDeviceFrame() - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun assertDeviceFrame() { await("actual-device-frame") { val image = SessionAcceptanceClient.image(admin.widget(sessionId)) val value = row() @@ -181,32 +195,55 @@ class DeviceScreenAcceptanceTest } } + /******************************************************************************* + ** + *******************************************************************************/ private fun row() = admin.sessions().single { it.getString("id") == sessionId } + + /******************************************************************************* + ** + *******************************************************************************/ private fun projectionNotification() = context.getSystemService(NotificationManager::class.java).activeNotifications.any { it.notification.channelId == "qqq-device-screen" } - private fun activityFocused(): Boolean - { + + /******************************************************************************* + ** + *******************************************************************************/ + private fun activityFocused(): Boolean { var focused = false instrumentation.runOnMainSync { focused = ui.activity.hasWindowFocus() } return focused } - private fun activePackage(): String? - { + + /******************************************************************************* + ** + *******************************************************************************/ + private fun activePackage(): String? { val root = instrumentation.uiAutomation.rootInActiveWindow ?: return null - return try { root.packageName?.toString() } finally { @Suppress("DEPRECATION") root.recycle() } + return try { + root.packageName?.toString() + } finally { + @Suppress("DEPRECATION") root.recycle() + } } - private fun signIn(password: String) - { + + /******************************************************************************* + ** + *******************************************************************************/ + private fun signIn(password: String) { waitFor("Username") ui.onNodeWithText("Username").performTextReplacement("scanner") ui.onNodeWithText("Password").performTextReplacement(password) ui.onNodeWithText("Password").performImeAction() click("Sign in", scroll = true) } - private fun click(text: String, scroll: Boolean = false) - { - if(text == "Log back in") await("completed-logout", 15) { + + /******************************************************************************* + ** + *******************************************************************************/ + private fun click(text: String, scroll: Boolean = false) { + if (text == "Log back in") await("completed-logout", 15) { ui.onAllNodesWithText("Signed out.").fetchSemanticsNodes().isNotEmpty() || ui.onAllNodesWithText("Signed out on this device.", substring = true).fetchSemanticsNodes().isNotEmpty() } @@ -214,80 +251,104 @@ class DeviceScreenAcceptanceTest ui.onAllNodes(hasText(text, substring = false) and hasClickAction() and isEnabled()).fetchSemanticsNodes().isNotEmpty() } val node = ui.onNode(hasText(text, substring = false) and hasClickAction()) - if(scroll) node.performScrollTo() + if (scroll) node.performScrollTo() node.assertIsEnabled().performClick() } + + /******************************************************************************* + ** + *******************************************************************************/ private fun waitFor(text: String) = await("app-control") { ui.onAllNodesWithText(text, substring = false).fetchSemanticsNodes().isNotEmpty() } + + /******************************************************************************* + ** + *******************************************************************************/ private fun stage(name: String) = instrumentation.sendStatus(0, Bundle().apply { putString("qqqStage", name) }) - private fun await(name: String, seconds: Long = 40, predicate: () -> Boolean) - { + + /******************************************************************************* + ** + *******************************************************************************/ + private fun await(name: String, seconds: Long = 40, predicate: () -> Boolean) { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds) var previous = System.nanoTime() - do - { + do { val now = System.nanoTime() ui.mainClock.advanceTimeBy(TimeUnit.NANOSECONDS.toMillis(now - previous).coerceAtLeast(16)) previous = now - if(predicate()) return + if (predicate()) return Thread.sleep(250) - } while(System.nanoTime() < deadline) + } while (System.nanoTime() < deadline) throw AssertionError("Device-screen stage did not complete within its bound: $name") } - private fun decoded(image: String, predicate: (Bitmap) -> Boolean): Boolean - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun decoded(image: String, predicate: (Bitmap) -> Boolean): Boolean { val bytes = Base64.decode(image.substringAfter(','), Base64.DEFAULT) val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false - return try { predicate(bitmap) } finally { bitmap.recycle() } + return try { + predicate(bitmap) + } finally { + bitmap.recycle() + } } - /** Display pixels are an independent in-memory oracle, never uploaded or written as an artifact. */ - private fun matchesDisplay(image: String?): Boolean - { - if(image == null) return false + /******************************************************************************* + ** Display pixels are an independent in-memory oracle, never uploaded or written as an artifact. + *******************************************************************************/ + private fun matchesDisplay(image: String?): Boolean { + if (image == null) return false val before = instrumentation.uiAutomation.takeScreenshot() ?: return false - try - { + try { val first = blocks(before) - if(!nonblank(first)) return false + if (!nonblank(first)) return false val agrees = decoded(image) { frame -> frame.width in 1..640 && frame.height in 1..1280 && abs(frame.width.toDouble() / frame.height - before.width.toDouble() / before.height) < 0.01 && compatible(first, blocks(frame)) } val after = instrumentation.uiAutomation.takeScreenshot() ?: return false - return try { agrees && error(first, blocks(after)).maxOrNull()!! <= 3.0 } finally { after.recycle() } + return try { + agrees && error(first, blocks(after)).maxOrNull()!! <= 3.0 + } finally { + after.recycle() + } + } finally { + before.recycle() } - finally { before.recycle() } } // 24x40 spatial block means preserve launcher structure while averaging JPEG ringing and resize interpolation. // Exclude outer10% (status/navigation/consent indicators); require mean RGB error<=12/255 and95%<=24/255. // This deliberately fails changed layouts/blank screens; it is not a perceptual hash or exact-pixel claim. - private fun blocks(bitmap: Bitmap): DoubleArray - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun blocks(bitmap: Bitmap): DoubleArray { val result = DoubleArray(24 * 40 * 3) - for(y in 0 until 40) for(x in 0 until 24) - { + for (y in 0 until 40) for (x in 0 until 24) { val left = (bitmap.width * (0.1 + 0.8 * x / 24)).toInt() val right = (bitmap.width * (0.1 + 0.8 * (x + 1) / 24)).toInt().coerceAtLeast(left + 1) val top = (bitmap.height * (0.1 + 0.8 * y / 40)).toInt() val bottom = (bitmap.height * (0.1 + 0.8 * (y + 1) / 40)).toInt().coerceAtLeast(top + 1) val i = (y * 24 + x) * 3 - for(py in top until bottom) for(px in left until right) - { + for (py in top until bottom) for (px in left until right) { val pixel = bitmap.getPixel(px, py) result[i] = result[i] + Color.red(pixel).toDouble() result[i + 1] = result[i + 1] + Color.green(pixel).toDouble() result[i + 2] = result[i + 2] + Color.blue(pixel).toDouble() } val count = (right - left) * (bottom - top) - for(channel in 0..2) result[i + channel] = result[i + channel] / count.toDouble() + for (channel in 0..2) result[i + channel] = result[i + channel] / count.toDouble() } return result } - private fun nonblank(values: DoubleArray): Boolean - { + + /******************************************************************************* + ** + *******************************************************************************/ + private fun nonblank(values: DoubleArray): Boolean { // Spatial variation, not variation between RGB channels: a solid red frame must also fail. return (0..2).any { channel -> val samples = values.indices.filter { it % 3 == channel }.map { values[it] } @@ -296,25 +357,35 @@ class DeviceScreenAcceptanceTest samples.maxOrNull()!! - samples.minOrNull()!! >= 48.0 } } + + /******************************************************************************* + ** + *******************************************************************************/ private fun error(first: DoubleArray, second: DoubleArray) = first.indices.map { abs(first[it] - second[it]) }.sorted() - private fun compatible(first: DoubleArray, second: DoubleArray): Boolean - { + + /******************************************************************************* + ** + *******************************************************************************/ + private fun compatible(first: DoubleArray, second: DoubleArray): Boolean { val errors = error(first, second) return nonblank(first) && nonblank(second) && errors.average() <= 12.0 && errors[(errors.size * 0.95).toInt()] <= 24.0 } - private fun comparatorControls() - { + + /******************************************************************************* + ** + *******************************************************************************/ + private fun comparatorControls() { val pattern = Bitmap.createBitmap(240, 400, Bitmap.Config.ARGB_8888) val blank = Bitmap.createBitmap(240, 400, Bitmap.Config.ARGB_8888) - try - { - for(y in 0 until pattern.height) for(x in 0 until pattern.width) - pattern.setPixel(x, y, if((x / 24 + y / 40) % 2 == 0) Color.WHITE else Color.BLACK) + try { + for (y in 0 until pattern.height) for (x in 0 until pattern.width) + pattern.setPixel(x, y, if ((x / 24 + y / 40) % 2 == 0) Color.WHITE else Color.BLACK) val reference = blocks(pattern) assertTrue("Comparator accepts identical nonblank control", compatible(reference, reference)) assertFalse("Comparator rejects blank frame", compatible(reference, blocks(blank))) assertFalse("Comparator rejects a wrong spatial layout", compatible(reference, reference.map { 255.0 - it }.toDoubleArray())) + } finally { + pattern.recycle(); blank.recycle() } - finally { pattern.recycle(); blank.recycle() } } } diff --git a/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerInjectionTest.kt b/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerInjectionTest.kt index 1c12f1c..674e101 100644 --- a/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerInjectionTest.kt +++ b/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerInjectionTest.kt @@ -47,18 +47,23 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -/** Real production scanner factory + separate ordinary UID. Only the disposable .qa host is permitted. */ +/******************************************************************************* + ** Real production scanner factory + separate ordinary UID. Only the disposable .qa host is permitted. + *******************************************************************************/ @RunWith(AndroidJUnit4::class) -class ScannerInjectionTest -{ - @get:Rule val ui = createAndroidComposeRule() +class ScannerInjectionTest { + @get:Rule + val ui = createAndroidComposeRule() private val client = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build() private lateinit var origin: String private lateinit var adminCookie: String private val probePackage = "io.qrun.mobile.scanner.probe" - @Test fun unrelatedUidCannotSubmitAProductionScan() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun unrelatedUidCannotSubmitAProductionScan() { val instrumentation = InstrumentationRegistry.getInstrumentation() val context = instrumentation.targetContext check(context.packageName == "io.qrun.mobile.scanner.qa" && BuildConfig.BUILD_TYPE == "qa") @@ -70,6 +75,9 @@ class ScannerInjectionTest val probeUid = requireNotNull(probe.applicationInfo).uid assertTrue("Probe must have an ordinary unrelated UID", probeUid >= 10000 && probeUid != context.applicationInfo.uid) assertTrue("Probe may not hold target or network permissions", probe.requestedPermissions.isNullOrEmpty()) + /******************************************************************************* + ** + *******************************************************************************/ fun signers(info: android.content.pm.PackageInfo) = requireNotNull(info.signingInfo).apkContentsSigners .map { MessageDigest.getInstance("SHA-256").digest(it.toByteArray()).toList() }.toSet() assertTrue("Root must re-sign the probe with a distinct throwaway test key", signers(probe).intersect(signers(target)).isEmpty()) @@ -77,16 +85,16 @@ class ScannerInjectionTest val configFile = File(context.filesDir, "qa-injection.json") val config = JSONObject(configFile.readText()) configFile.delete() - val expectedBarcode = if(config.has("expectedBarcode")) - { + val expectedBarcode = if (config.has("expectedBarcode")) { val value = config.get("expectedBarcode") check(value is String && value.length in 1..4096 && value.all { it.code in 32..126 }) value - } - else null + } else null val server = URI(config.getString("serverUrl")) - check(server.scheme == "http" && server.host == "127.0.0.1" && server.port > 0 && - server.userInfo == null && server.query == null && server.fragment == null && server.path == "/") + check( + server.scheme == "http" && server.host == "127.0.0.1" && server.port > 0 && + server.userInfo == null && server.query == null && server.fragment == null && server.path == "/" + ) origin = server.toString().trimEnd('/') waitFor("QQQ server URL") ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/") @@ -98,16 +106,18 @@ class ScannerInjectionTest ui.onNode(hasText("Sign in") and hasClickAction()).performScrollTo().assertIsDisplayed().assertIsEnabled().performClick() waitFor("An administrator must approve this device before you can run mobile apps.") val device = DeviceCredentials(context, "$origin/") - adminCookie = client.newCall(Request.Builder().url("$origin/qqq/v1/manageSession") - .header("Authorization", Credentials.basic("admin", config.getString("adminPassword"))) - .post("{}".toRequestBody("application/json".toMediaType())).build()).execute().use { + adminCookie = client.newCall( + Request.Builder().url("$origin/qqq/v1/manageSession") + .header("Authorization", Credentials.basic("admin", config.getString("adminPassword"))) + .post("{}".toRequestBody("application/json".toMediaType())).build() + ).execute().use { assertEquals(200, it.code) it.headers("Set-Cookie").first { cookie -> cookie.startsWith("sessionUUID=") }.substringBefore(';') } val launch = process("mobileDeviceApprove", "/init", JSONObject()) val id = launch.getString("processUUID") val applied = process("mobileDeviceApprove", "/$id/step/input", JSONObject().put("deviceId", device.deviceId)) - if(applied.optString("nextStep") == "result") process("mobileDeviceApprove", "/$id/step/result", JSONObject()) + if (applied.optString("nextStep") == "result") process("mobileDeviceApprove", "/$id/step/result", JSONObject()) ui.onNodeWithText("Check approval").performScrollTo().performClick() waitForTag("appHome.rowForApp:mobileScanApp") ui.onNodeWithTag("appHome.rowForApp:mobileScanApp").performClick() @@ -128,12 +138,12 @@ class ScannerInjectionTest val routed = java.util.concurrent.CopyOnWriteArrayList() val delivered = CountDownLatch(4) // Route control in instrumentation only; it observes payloads and NEVER invokes a process/driver. - val observer = object : BroadcastReceiver() - { - override fun onReceive(receiverContext: Context?, intent: Intent?) - { - if(intent?.getStringExtra("com.symbol.datawedge.data_string")?.startsWith("$marker-route-") == true) - { + val observer = object : BroadcastReceiver() { + /******************************************************************************* + ** + *******************************************************************************/ + override fun onReceive(receiverContext: Context?, intent: Intent?) { + if (intent?.getStringExtra("com.symbol.datawedge.data_string")?.startsWith("$marker-route-") == true) { routed.add(Intent(intent)) delivered.countDown() } @@ -143,21 +153,25 @@ class ScannerInjectionTest ContextCompat.registerReceiver(context, observer, IntentFilter("io.qrun.mobile.scanner.SCAN").apply { addCategory(Intent.CATEGORY_DEFAULT) }, ContextCompat.RECEIVER_EXPORTED) - try - { + try { emitProbe(context, "$marker-route", probeUid) assertTrue("Actual SCAN payload must reach the route observer", delivered.await(10, TimeUnit.SECONDS)) assertEquals((0 until 4).map { "$marker-route-$it" }.toSet(), routed.map { it.getStringExtra("com.symbol.datawedge.data_string") }.toSet()) - assertTrue(routed.all { it.action == "io.qrun.mobile.scanner.SCAN" && - it.getStringExtra("com.symbol.datawedge.label_type") == "LABEL-TYPE-CODE128" && - it.getStringExtra("com.symbol.datawedge.decode_mode") == "single" }) + assertTrue(routed.all { + it.action == "io.qrun.mobile.scanner.SCAN" && + it.getStringExtra("com.symbol.datawedge.label_type") == "LABEL-TYPE-CODE128" && + it.getStringExtra("com.symbol.datawedge.decode_mode") == "single" + }) foreground(context) + } finally { + context.unregisterReceiver(observer) } - finally { context.unregisterReceiver(observer) } // The route observer is absent during the actual negative attempt. emitProbe(context, "$marker-negative", probeUid) val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) - while(System.nanoTime() < deadline) { foreground(context); Thread.sleep(100) } + while (System.nanoTime() < deadline) { + foreground(context); Thread.sleep(100) + } val after = scans() assertEquals("Unrelated-UID broadcasts must not create any scan", before.size, after.size) assertFalse(after.any { it.optString("barcode").startsWith(marker) }) @@ -169,8 +183,7 @@ class ScannerInjectionTest putInt("qqqTargetUid", context.applicationInfo.uid) putInt("qqqForgedScanCount", 8) }) - if(expectedBarcode != null) - { + if (expectedBarcode != null) { val events = mutableSetOf() physicalScan(context, device.deviceId, expectedBarcode, "initial", after.size, events) ui.activityRule.scenario.recreate() @@ -189,9 +202,13 @@ class ScannerInjectionTest } } - private fun physicalScan(context: Context, deviceId: String, expectedBarcode: String, phase: String, - expectedBefore: Int, events: MutableSet) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun physicalScan( + context: Context, deviceId: String, expectedBarcode: String, phase: String, + expectedBefore: Int, events: MutableSet + ) { waitFor("Scanner ready. Press the handheld trigger.") foreground(context) val before = scans() @@ -199,34 +216,39 @@ class ScannerInjectionTest val priorIds = before.map { it.getString("id") }.toSet() val priorEvents = before.map { it.getString("eventId") }.toSet() val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120) + + /******************************************************************************* + ** + *******************************************************************************/ fun remainingMillis() = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(1) - fun pollScans(stage: String): List - { + + /******************************************************************************* + ** + *******************************************************************************/ + fun pollScans(stage: String): List { val remaining = remainingMillis() // Do not start a near-deadline 1ms HTTP call that hides the actual operator wait failure. - if(remaining < 1000) - { + if (remaining < 1000) { Thread.sleep(remaining) throw AssertionError("Physical trigger deadline reached during $stage; no additional polling budget remains") } - return try { scans(remaining.coerceAtMost(20_000)) } - catch(error: java.io.InterruptedIOException) - { - if(System.nanoTime() >= deadline) + return try { + scans(remaining.coerceAtMost(20_000)) + } catch (error: java.io.InterruptedIOException) { + if (System.nanoTime() >= deadline) throw AssertionError("Physical trigger deadline reached during $stage; the final status poll did not complete", error) throw error } } InstrumentationRegistry.getInstrumentation().sendStatus(0, Bundle().apply { putString("qqqStage", "hardware-$phase-ready") }) var saved: JSONObject? = null - while(saved == null) - { + while (saved == null) { assertTrue("Timed out waiting for the operator's physical trigger", System.nanoTime() < deadline) foreground(context, remainingMillis().coerceAtMost(5000)) val current = pollScans("waiting for the operator's acknowledged scan") val added = current.filter { it.getString("id") !in priorIds } assertTrue("One trigger must not create multiple records", added.size <= 1 && current.size <= before.size + 1) - if(added.isNotEmpty()) saved = added.single() else Thread.sleep(200) + if (added.isNotEmpty()) saved = added.single() else Thread.sleep(200) } val row = requireNotNull(saved) assertTrue("Hardware barcode must exactly match the configured synthetic value", row.getString("barcode") == expectedBarcode) @@ -243,21 +265,26 @@ class ScannerInjectionTest InstrumentationRegistry.getInstrumentation().sendStatus(0, Bundle().apply { putString("qqqStage", "hardware-$phase-saved") }) } - private fun emitProbe(context: Context, marker: String, probeUid: Int) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun emitProbe(context: Context, marker: String, probeUid: Int) { val completed = CountDownLatch(1) val result = AtomicReference() - context.sendOrderedBroadcast(Intent("io.qrun.mobile.scanner.probe.EMIT") - .setComponent(ComponentName(probePackage, "$probePackage.ScanProbeReceiver")) - .putExtra("targetPackage", context.packageName).putExtra("marker", marker), null, - object : BroadcastReceiver() - { - override fun onReceive(receiverContext: Context?, intent: Intent?) - { + context.sendOrderedBroadcast( + Intent("io.qrun.mobile.scanner.probe.EMIT") + .setComponent(ComponentName(probePackage, "$probePackage.ScanProbeReceiver")) + .putExtra("targetPackage", context.packageName).putExtra("marker", marker), null, + object : BroadcastReceiver() { + /******************************************************************************* + ** + *******************************************************************************/ + override fun onReceive(receiverContext: Context?, intent: Intent?) { result.set(getResultExtras(false)?.apply { putInt("resultCode", resultCode) }) completed.countDown() } - }, null, 0, null, null) + }, null, 0, null, null + ) assertTrue("Separate probe must finish", completed.await(10, TimeUnit.SECONDS)) assertEquals(1, result.get()?.getInt("resultCode")) assertEquals(probeUid, result.get()?.getInt("senderUid")) @@ -265,13 +292,14 @@ class ScannerInjectionTest assertEquals(marker, result.get()?.getString("marker")) } - private fun foreground(context: Context, timeoutMillis: Long = 5000) - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun foreground(context: Context, timeoutMillis: Long = 5000) { val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) var ready = false var flags = "unavailable" - do - { + do { ui.activityRule.scenario.onActivity { val resumed = it.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) val focused = it.window.decorView.hasWindowFocus() @@ -280,31 +308,39 @@ class ScannerInjectionTest flags = "resumed=$resumed, focused=$focused, interactive=$interactive, locked=$locked" ready = resumed && focused && interactive && !locked } - if(ready) return + if (ready) return val remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()) - if(remaining > 0) Thread.sleep(remaining.coerceAtMost(50)) - } - while(System.nanoTime() < deadline) + if (remaining > 0) Thread.sleep(remaining.coerceAtMost(50)) + } while (System.nanoTime() < deadline) assertTrue("Target must regain resumed, unlocked foreground focus: $flags", ready) } - private fun scans(timeoutMillis: Long = 20_000): List - { - val rows = json(Request.Builder().url("$origin/qqq/v1/table/mobileScan/query").header("Cookie", adminCookie) - .post("{}".toRequestBody("application/json".toMediaType())).build(), timeoutMillis).getJSONArray("records") + /******************************************************************************* + ** + *******************************************************************************/ + private fun scans(timeoutMillis: Long = 20_000): List { + val rows = json( + Request.Builder().url("$origin/qqq/v1/table/mobileScan/query").header("Cookie", adminCookie) + .post("{}".toRequestBody("application/json".toMediaType())).build(), timeoutMillis + ).getJSONArray("records") return (0 until rows.length()).map { rows.getJSONObject(it).getJSONObject("values") } } - private fun process(name: String, suffix: String, values: JSONObject): JSONObject - { - var result = json(Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", adminCookie) - .post(MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString()) - .addFormDataPart("stepTimeoutMillis", "10000").build()).build()) + /******************************************************************************* + ** + *******************************************************************************/ + private fun process(name: String, suffix: String, values: JSONObject): JSONObject { + var result = json( + Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", adminCookie) + .post( + MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString()) + .addFormDataPart("stepTimeoutMillis", "10000").build() + ).build() + ) val processId = result.optString("processUUID") val jobId = result.optString("jobUUID") val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30) - while(result.optString("type") in listOf("JOB_STARTED", "RUNNING")) - { + while (result.optString("type") in listOf("JOB_STARTED", "RUNNING")) { assertTrue(System.nanoTime() < deadline) Thread.sleep(50) result = json(Request.Builder().url("$origin/qqq/v1/processes/$name/$processId/status/$jobId").header("Cookie", adminCookie).build()) @@ -313,8 +349,10 @@ class ScannerInjectionTest return result } - private fun json(request: Request, timeoutMillis: Long = 20_000): JSONObject - { + /******************************************************************************* + ** + *******************************************************************************/ + private fun json(request: Request, timeoutMillis: Long = 20_000): JSONObject { val call = client.newCall(request) call.timeout().timeout(timeoutMillis, TimeUnit.MILLISECONDS) return call.execute().use { @@ -322,8 +360,18 @@ class ScannerInjectionTest JSONObject(requireNotNull(it.body).string()) } } - private fun waitFor(text: String, substring: Boolean = false, timeoutMillis: Long = 60_000) - { ui.waitUntil(timeoutMillis) { ui.onAllNodesWithText(text, substring = substring).fetchSemanticsNodes().isNotEmpty() } } - private fun waitForTag(tag: String) - { ui.waitUntil(60_000) { runCatching { ui.onNodeWithTag(tag).fetchSemanticsNode() }.isSuccess } } + + /******************************************************************************* + ** + *******************************************************************************/ + private fun waitFor(text: String, substring: Boolean = false, timeoutMillis: Long = 60_000) { + ui.waitUntil(timeoutMillis) { ui.onAllNodesWithText(text, substring = substring).fetchSemanticsNodes().isNotEmpty() } + } + + /******************************************************************************* + ** + *******************************************************************************/ + private fun waitForTag(tag: String) { + ui.waitUntil(60_000) { runCatching { ui.onNodeWithTag(tag).fetchSemanticsNode() }.isSuccess } + } } diff --git a/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerOverlayTest.kt b/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerOverlayTest.kt index 9c55a1a..8b875a9 100644 --- a/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerOverlayTest.kt +++ b/scanner-host/src/qaAndroidTest/java/io/qrun/mobile/scanner/ScannerOverlayTest.kt @@ -11,7 +11,17 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.test.* +import androidx.compose.ui.test.ComposeTimeoutException +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.navigation.compose.rememberNavController import androidx.core.view.ViewCompat @@ -19,16 +29,31 @@ import androidx.core.view.WindowInsetsCompat import androidx.test.platform.app.InstrumentationRegistry import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository import com.kingsrook.qqq.frontend.android.core.model.Environment -import com.kingsrook.qqq.frontend.android.core.model.metadata.* -import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.* -import com.kingsrook.qqq.frontend.android.core.model.processes.* +import com.kingsrook.qqq.frontend.android.core.model.metadata.QAppMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QAppNodeType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QAppSection +import com.kingsrook.qqq.frontend.android.core.model.metadata.QAppTreeNode +import com.kingsrook.qqq.frontend.android.core.model.metadata.QComponentType +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendComponent +import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendStepMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance +import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.FullyAnonymousAuthenticationMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionRequest +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse +import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult +import com.kingsrook.qqq.frontend.android.core.model.processes.QJobComplete import com.kingsrook.qqq.frontend.android.mobileapp.QMobileAppMain import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalCustomWidgetRenderer import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.After import org.junit.Before import org.junit.Rule @@ -36,21 +61,29 @@ import org.junit.Test import kotlinx.coroutines.cancel import java.util.concurrent.atomic.AtomicReference -/** Production widget and navigation surfaces; only the private hardware driver is synthetic. */ -class ScannerOverlayTest -{ - @get:Rule val ui = createAndroidComposeRule() +/******************************************************************************* + ** Production widget and navigation surfaces; only the private hardware driver is synthetic. + *******************************************************************************/ +class ScannerOverlayTest { + @get:Rule + val ui = createAndroidComposeRule() private var previousEnvironments: List? = null private var fixtureModel: QViewModel? = null - @Before fun configureSyntheticEnvironment() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Before + fun configureSyntheticEnvironment() { previousEnvironments = runCatching { QAppContainer.availableEnvironments }.getOrNull() QAppContainer.availableEnvironments = listOf(Environment("QA overlay", "https://overlay.invalid/")) } - @After fun disposeSyntheticEnvironment() - { + /******************************************************************************* + ** + *******************************************************************************/ + @After + fun disposeSyntheticEnvironment() { ui.activityRule.scenario.onActivity { fixtureModel?.activeProcessViewModel?.reset() fixtureModel?.viewModelScope?.cancel() @@ -59,23 +92,62 @@ class ScannerOverlayTest } } - private class Device : ScannerDriver - { + /******************************************************************************* + ** + *******************************************************************************/ + private class Device : ScannerDriver { lateinit var listener: ScannerDriver.Listener var pending = false var closed = false var reads = 0 var cancels = 0 - override fun open(listener: ScannerDriver.Listener) { this.listener = listener; listener.idle() } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun open(listener: ScannerDriver.Listener) { + this.listener = listener; listener.idle() + } + + /******************************************************************************* + ** + *******************************************************************************/ override fun isReadPending() = pending - override fun read() { check(!closed); reads++; pending = true } - override fun cancelRead() { cancels++; pending = false; listener.idle() } - override fun close() { closed = true; pending = false } - fun emit(value: String) { pending = false; listener.scanned(value, "QRCODE"); listener.idle() } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun read() { + check(!closed); reads++; pending = true + } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun cancelRead() { + cancels++; pending = false; listener.idle() + } + + /******************************************************************************* + ** + *******************************************************************************/ + override fun close() { + closed = true; pending = false + } + + /******************************************************************************* + ** + *******************************************************************************/ + fun emit(value: String) { + pending = false; listener.scanned(value, "QRCODE"); listener.idle() + } } - @Test fun overlaysAndLostWindowFocusBlockScansWithoutLosingDraftOrTrappingResume() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun overlaysAndLostWindowFocusBlockScansWithoutLosingDraftOrTrappingResume() { check(InstrumentationRegistry.getInstrumentation().targetContext.packageName == "io.qrun.mobile.scanner.qa") val repository = Repository() val model = QViewModel(repository, MockPreferencesDataStore()).apply { supportedCustomWidgetTypes = setOf("SCANNER") } @@ -90,7 +162,7 @@ class ScannerOverlayTest CompositionLocalProvider( LocalProcessInputAllowed provides { parentAllowed.value }, LocalCustomWidgetRenderer provides { parameters, modifier, disabled -> - if(parameters.widgetBlock.blockType == "SCANNER") { + if (parameters.widgetBlock.blockType == "SCANNER") { val availability = LocalProcessInputAllowed.current SideEffect { observedInputAllowed.set(availability) } ScannerWidget(parameters, modifier, disabled, driverFactory) @@ -98,7 +170,8 @@ class ScannerOverlayTest } else false }) { QMobileAppMain(model, "Scanner overlay test", showNetworkDialog = false) } // Deliberately outside semantic availability: proves actual window focus is independently checked. - if(externalDialog.value) AlertDialog(onDismissRequest = { externalDialog.value = false }, + if (externalDialog.value) AlertDialog( + onDismissRequest = { externalDialog.value = false }, title = { Text("External window") }, confirmButton = { TextButton(onClick = { externalDialog.value = false }) { Text("Close external") } }) @@ -108,11 +181,19 @@ class ScannerOverlayTest ui.waitUntil(5000) { model.isAuthenticated } ui.onNodeWithTag("appHome.rowForApp:app").performClick() ui.onNodeWithTag("appHome.rowForProcess:scan").performClick() + /******************************************************************************* + ** + *******************************************************************************/ fun imeHidden() = ViewCompat.getRootWindowInsets(ui.activity.window.decorView) ?.isVisible(WindowInsetsCompat.Type.ime()) == false + + /******************************************************************************* + ** + *******************************************************************************/ fun ready(phase: String) { - try { ui.waitUntil(5000) { devices.lastOrNull()?.pending == true && ui.activity.hasWindowFocus() } } - catch(error: ComposeTimeoutException) { + try { + ui.waitUntil(5000) { devices.lastOrNull()?.pending == true && ui.activity.hasWindowFocus() } + } catch (error: ComposeTimeoutException) { val drawerVisible = runCatching { ui.onNodeWithTag("drawer.settings").assertIsDisplayed(); true }.getOrDefault(false) val closeVisible = runCatching { ui.onNodeWithText("Close").assertIsDisplayed(); true }.getOrDefault(false) var flags = "unavailable" @@ -128,12 +209,20 @@ class ScannerOverlayTest throw AssertionError("Scanner did not resume at $phase: $flags", error) } } + + /******************************************************************************* + ** + *******************************************************************************/ fun reject() { val count = repository.events.size ui.runOnIdle { devices.last().emit("blocked") } ui.waitForIdle() assertEquals("Overlay must not submit a step", count, repository.events.size) } + + /******************************************************************************* + ** + *******************************************************************************/ fun resumeAndScan(phase: String) { ready(phase) ui.onNodeWithText("Manual barcode").assertTextContains("draft") @@ -142,6 +231,10 @@ class ScannerOverlayTest ui.waitUntil(5000) { repository.events.size == count + 1 && model.activeProcessViewModel?.pendingStepValues == null } ready("$phase-acknowledged") } + + /******************************************************************************* + ** + *******************************************************************************/ fun seedDraft(phase: String) { // Each successful scan submits/remounts the step; preservation applies within that step. ready("$phase-before-overlay") @@ -167,7 +260,7 @@ class ScannerOverlayTest assertEquals(0, repository.cancellations) } - for(destination in listOf("settings", "diagnostics")) { + for (destination in listOf("settings", "diagnostics")) { seedDraft(destination) ui.onNodeWithTag("topBar.menuIcon").performClick() ui.onNodeWithTag("drawer.$destination").performClick() @@ -219,35 +312,100 @@ class ScannerOverlayTest assertTrue(model.isAuthenticated) } - private fun waitFor(text: String) { ui.waitUntil(5000) { ui.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() } } + /******************************************************************************* + ** + *******************************************************************************/ + private fun waitFor(text: String) { + ui.waitUntil(5000) { ui.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() } + } - private class Repository : QQQRepository - { + /******************************************************************************* + ** + *******************************************************************************/ + private class Repository : QQQRepository { val events = mutableListOf>() var cancellations = 0 + // Match decoded server JSON containers: WidgetBlock owns mutable values/styles. - private val scannerComponent = QFrontendComponent(QComponentType.WIDGET, - mutableMapOf("isAdHocWidget" to true, "blocks" to listOf( - mutableMapOf("blockId" to "scan", "blockType" to "SCANNER", - "styles" to mutableMapOf(), "values" to mutableMapOf())))) - private val metadata = QProcessMetaData("scan", "Scan", frontendSteps = listOf( - QFrontendStepMetaData("input", "Input", components = listOf(scannerComponent)))) - override fun setEnvironment(environment: Environment) { } - override fun setSessionUUID(sessionUUID: String) { } + private val scannerComponent = QFrontendComponent( + QComponentType.WIDGET, + mutableMapOf( + "isAdHocWidget" to true, "blocks" to listOf( + mutableMapOf( + "blockId" to "scan", "blockType" to "SCANNER", + "styles" to mutableMapOf(), "values" to mutableMapOf() + ) + ) + ) + ) + private val metadata = QProcessMetaData( + "scan", "Scan", frontendSteps = listOf( + QFrontendStepMetaData("input", "Input", components = listOf(scannerComponent)) + ) + ) + + /******************************************************************************* + ** + *******************************************************************************/ + override fun setEnvironment(environment: Environment) {} + + /******************************************************************************* + ** + *******************************************************************************/ + override fun setSessionUUID(sessionUUID: String) {} + + /******************************************************************************* + ** + *******************************************************************************/ override fun getURI(path: String) = "https://unused.invalid/$path" + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = FullyAnonymousAuthenticationMetaData("FULLY_ANONYMOUS") + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun manageSession(body: ManageSessionRequest) = ManageSessionResponse("synthetic-overlay-session") + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String) = QInstance( appTree = listOf(QAppTreeNode("app", "App", QAppNodeType.APP)), apps = mapOf("app" to QAppMetaData("app", "App", sections = listOf(QAppSection("section", "Section", processes = listOf("scan"))))), - processes = mapOf("scan" to metadata)) + processes = mapOf("scan" to metadata) + ) + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun getProcessMetaData(processName: String) = metadata + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?) = QJobComplete("synthetic-process", nextStep = "input") + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult { events.add(values.toMap()) return QJobComplete(processUUID, mapOf("acknowledgedEventId" to values["eventId"], "scanSaved" to true, "scanId" to events.size.toString()), "input") } - override suspend fun processCancel(processName: String, processUUID: String) { cancellations++ } + + /******************************************************************************* + ** + *******************************************************************************/ + override suspend fun processCancel(processName: String, processUUID: String) { + cancellations++ + } + + /******************************************************************************* + ** + *******************************************************************************/ override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult = error("No jobs in this fixture") } } diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/DiagnosticPresentationTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/DiagnosticPresentationTest.kt index dd5b94d..ce2a415 100644 --- a/scanner-host/src/test/java/io/qrun/mobile/scanner/DiagnosticPresentationTest.kt +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/DiagnosticPresentationTest.kt @@ -3,15 +3,26 @@ package io.qrun.mobile.scanner import java.time.Instant import java.time.ZoneId -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -class DiagnosticPresentationTest -{ +/******************************************************************************* + ** + *******************************************************************************/ +class DiagnosticPresentationTest { + /******************************************************************************* + ** + *******************************************************************************/ private fun event(code: DiagnosticCode, status: Int? = 200) = DiagnosticEvent(Instant.parse("2026-11-01T18:00:00Z"), code, status, 12) - @Test fun problemsIncludeTransportHttpAndInvalidSuccessfulResponses() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun problemsIncludeTransportHttpAndInvalidSuccessfulResponses() { val success = event(DiagnosticCode.CONNECTION_OK) val forbidden = event(DiagnosticCode.DEVICE_RESPONSE, 403) val malformed = event(DiagnosticCode.CONNECTION_FAILED) @@ -22,8 +33,11 @@ class DiagnosticPresentationTest assertTrue(transport.detail().contains("No HTTP response")) } - @Test fun connectionSummaryNeverUsesAppDeviceOrSessionResponses() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun connectionSummaryNeverUsesAppDeviceOrSessionResponses() { val check = event(DiagnosticCode.CONNECTION_FAILED, 503) val activity = listOf(event(DiagnosticCode.DEVICE_RESPONSE), event(DiagnosticCode.SESSION_RESPONSE), event(DiagnosticCode.NATIVE_RESPONSE)) assertNull(latestConnectionCheck(activity)) @@ -32,8 +46,11 @@ class DiagnosticPresentationTest assertEquals("Session request completed", activity[1].title()) } - @Test fun exportUsesFriendlyLabelsLocalDateAndFixedFieldsOnly() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun exportUsesFriendlyLabelsLocalDateAndFixedFieldsOnly() { val events = DiagnosticCode.values().map { event(it, 401) } val report = diagnosticReport(events, false, ZoneId.of("America/Chicago")) assertTrue(report.contains("2026-11-01 12:00:00 CST")) @@ -44,8 +61,11 @@ class DiagnosticPresentationTest assertTrue(diagnosticReport(emptyList(), true).endsWith("No events.")) } - @Test fun snapshotsAreIndependentBoundedAndClearable() - { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun snapshotsAreIndependentBoundedAndClearable() { val log = DiagnosticLog(2) log.record(DiagnosticCode.CONNECTION_OK, 200, 1) val saved = log.snapshot() diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/HostRuntimeTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/HostRuntimeTest.kt index e4ef8e2..67230d3 100644 --- a/scanner-host/src/test/java/io/qrun/mobile/scanner/HostRuntimeTest.kt +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/HostRuntimeTest.kt @@ -1,17 +1,25 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ package io.qrun.mobile.scanner import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome -class HostRuntimeTest -{ +/******************************************************************************* + ** + *******************************************************************************/ +class HostRuntimeTest { + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun requestDiagnosticsDistinguishNativeAndDeviceOutcomes() - { + fun requestDiagnosticsDistinguishNativeAndDeviceOutcomes() { val log = DiagnosticLog() log.nativeRequest(RequestOutcome(401, 10)) log.nativeRequest(RequestOutcome(null, 20)) @@ -23,10 +31,12 @@ class HostRuntimeTest assertTrue(log.text().contains("DEVICE_TRANSPORT_FAILED HTTP=none 40ms")) } + /******************************************************************************* + ** + *******************************************************************************/ @Test - fun malformedBrandFallsBackAndUnsafeLogoIsDiscarded() - { - for(payload in listOf("not json", "[]", "null", "{\"displayName\":12,\"primaryColor\":\"red\"}")) + fun malformedBrandFallsBackAndUnsafeLogoIsDiscarded() { + for (payload in listOf("not json", "[]", "null", "{\"displayName\":12,\"primaryColor\":\"red\"}")) assertEquals(HostBrand(), HostBrand.parse(payload)) assertEquals(HostBrand("Acme", "#AB12FF"), HostBrand.parse("{\"displayName\":\" Acme \",\"primaryColor\":\"#ab12ff\",\"logoUrl\":\"http://example.com/logo.png\"}")) assertEquals(HostBrand().displayName, HostBrand.parse("{\"displayName\":\"