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