fix(quality): apply client standards review and add Query About
Preserve ownership notices, replace wildcard imports, document source contracts, enforce source checks and strict Kotlin warnings, and apply the repository native formatter policy. Correct typed widget state and callback registration with regression coverage. Add bundled Query artwork, public Dogfooding and Query links, version and license access under Settings About. Validation:163 JVM cases,46 TC22 sample native cases, QA compilation, APK builds and lint0errors/0warnings. Full formatter CI and actual external-provider Android acceptance remain explicit. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
236 files changed
+8687
-5436
No files matched your search
@@ -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"
|
||||
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
<!-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0 -->
|
||||
<code_scheme name="QQQ Android repository" version="173">
|
||||
<option name="RIGHT_MARGIN" value="1000000" />
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="3" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="3" />
|
||||
<option name="TAB_SIZE" value="3" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
<project version="4">
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
kotlinOptions.allWarningsAsErrors = true
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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<Any>` 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+11
-10
@@ -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,19 +27,19 @@ 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)
|
||||
|
||||
+123
-127
@@ -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<String, String> = { emptyMap() }) : QQQRepository
|
||||
{
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val compatibility: UiApiCompatibility? = null, private val additionalHeaders: (String) -> Map<String, String> = { emptyMap() }) : QQQRepository {
|
||||
private val sessionUUIDs = ConcurrentHashMap<String, String>()
|
||||
private val retroJson = Json { ignoreUnknownKeys = true }
|
||||
private val compatibilityByServer = ConcurrentHashMap<String, UiApiCompatibility>()
|
||||
|
||||
private val qqqApiServiceMap: MutableMap<String, QQQApiService> = 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>): 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>): 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<NativeLogoutResponse>(source.readUtf8()).nativeLogoutData }
|
||||
catch(exception: Exception) { null }
|
||||
if (source.buffer.size > 65536) return@use null
|
||||
try {
|
||||
retroJson.decodeFromString<NativeLogoutResponse>(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<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult
|
||||
{
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
override suspend fun processInit(processName: String, values: Map<String, Any?>, 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<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult
|
||||
{
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, 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<String, Any?>, recordsParam: String? = null, recordIds: String? = null, filterJSON: String? = null, stepTimeoutMillis: Int? = null): RequestBody
|
||||
{
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
private fun buildProcessInitOrStepRequestBodyV1(values: Map<String, Any?>, 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()
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
}
|
||||
+66
-15
@@ -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<ManageSessionResponse>
|
||||
suspend fun manageSession(
|
||||
@Body body: ManageSessionRequest,
|
||||
@retrofit2.http.Tag cookiePolicy: DeferredSessionCookie
|
||||
): retrofit2.Response<ManageSessionResponse>
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<ManageSessionResponse>
|
||||
suspend fun exchangeNativeSession(
|
||||
@Body body: ManageSessionRequest,
|
||||
@retrofit2.http.Tag cookiePolicy: DeferredSessionCookie
|
||||
): retrofit2.Response<ManageSessionResponse>
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String, String>,
|
||||
@retrofit2.http.Tag cookiePolicy: DeferredSessionCookie): retrofit2.Response<ManageSessionResponse>
|
||||
suspend fun loginWithPassword(
|
||||
@Header("Authorization") authorization: String, @Body body: Map<String, String>,
|
||||
@retrofit2.http.Tag cookiePolicy: DeferredSessionCookie
|
||||
): retrofit2.Response<ManageSessionResponse>
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
|
||||
|
||||
+67
-18
@@ -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<String, Any?>, 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<String, Any?>, 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
|
||||
}
|
||||
|
||||
+18
-13
@@ -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
|
||||
}
|
||||
|
||||
+63
-34
@@ -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<String> = emptyList(), val extensions: List<UiApiExtension> = emptyList(),
|
||||
val clientChannels: List<String> = emptyList())
|
||||
data class UiApiVersion(
|
||||
val apiVersion: String, val features: List<String> = emptyList(), val extensions: List<UiApiExtension> = emptyList(),
|
||||
val clientChannels: List<String> = 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<UiApiVersion>(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<UiApiVersion>(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.")
|
||||
}
|
||||
}
|
||||
|
||||
+39
-17
@@ -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<UiApiFieldError> = emptyList(), val traceId: String? = null)
|
||||
private data class UiApiErrorBody(
|
||||
val message: String? = null, val code: String? = null,
|
||||
val fieldErrors: List<UiApiFieldError> = 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<UiApiFieldError> = 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<UiApiFieldError> = 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<UiApiErrorBody>(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
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -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,
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
+80
-32
@@ -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<String, @Serializable(with = AnyValueSerializer::
|
||||
typealias MutableMapOfStringAny = MutableMap<String, @Serializable(with = AnyValueSerializer::class) Any?>
|
||||
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<AnySerializableValue>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Delegate wrapped values to the JSON data serializer without class discovery.
|
||||
*******************************************************************************/
|
||||
object AnySerializableValueSerializer : KSerializer<AnySerializableValue> {
|
||||
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<String, AnySerializableValue>
|
||||
typealias SerializableListType = MutableList<AnySerializableValue>
|
||||
|
||||
/** 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<Any?>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Semantic API values are JSON data; business keys never select JVM classes.
|
||||
*******************************************************************************/
|
||||
object AnyValueSerializer : KSerializer<Any?> {
|
||||
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<Any?>
|
||||
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<String, Any?> = emptyMap())
|
||||
|
||||
/** Delegate object framing to the standard map serializer. */
|
||||
object MapStringAnySerializer : KSerializer<MapStringAny>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Delegate object framing to the standard map serializer.
|
||||
*******************************************************************************/
|
||||
object MapStringAnySerializer : KSerializer<MapStringAny> {
|
||||
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<Any?> = emptyList())
|
||||
|
||||
/** Delegate list framing to the standard list serializer. */
|
||||
object ListAnySerializer : KSerializer<ListAny>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Delegate list framing to the standard list serializer.
|
||||
*******************************************************************************/
|
||||
object ListAnySerializer : KSerializer<ListAny> {
|
||||
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))
|
||||
}
|
||||
+4
-5
@@ -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
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
}
|
||||
+16
-7
@@ -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<AdornmentType>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Keep unknown server enum values representable so additive metadata fails safely at the UI boundary.
|
||||
*******************************************************************************/
|
||||
object AdornmentTypeSerializer : kotlinx.serialization.KSerializer<AdornmentType> {
|
||||
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)
|
||||
}
|
||||
+4
-5
@@ -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()
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
}
|
||||
+4
-6
@@ -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<String>? = emptyList(),
|
||||
val sections: List<QAppSection>? = emptyList(),
|
||||
val keywords: List<String>? = emptyList(),
|
||||
// todo - not Any... val supplementalAppMetaData: Map<String, Any> = emptyMap()
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
}
|
||||
+16
-7
@@ -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<QAppNodeType>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Keep unknown server enum values representable so additive metadata fails safely at the UI boundary.
|
||||
*******************************************************************************/
|
||||
object QAppNodeTypeSerializer : kotlinx.serialization.KSerializer<QAppNodeType> {
|
||||
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)
|
||||
}
|
||||
+8
-12
@@ -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<String>? = 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)
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -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<QAppTreeNode>? = emptyList(),
|
||||
val icon: QIcon? = null,
|
||||
val keywords: List<String>? = emptyList(),
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
}
|
||||
+4
-5
@@ -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,
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
}
|
||||
+16
-7
@@ -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<QComponentType>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Keep unknown server enum values representable so additive metadata fails safely at the UI boundary.
|
||||
*******************************************************************************/
|
||||
object QComponentTypeSerializer : kotlinx.serialization.KSerializer<QComponentType> {
|
||||
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)
|
||||
}
|
||||
+7
-6
@@ -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<QHelpContent>? = null,
|
||||
val isHidden: Boolean = false,
|
||||
val validation: FieldValidation? = null,
|
||||
// todo - not Any... val behaviors: List<Any>?,
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Carry semantic field constraints so native validation follows the server contract.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class FieldValidation(
|
||||
val required: Boolean? = null,
|
||||
|
||||
+16
-7
@@ -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<QFieldType>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Keep unknown server enum values representable so additive metadata fails safely at the UI boundary.
|
||||
*******************************************************************************/
|
||||
object QFieldTypeSerializer : kotlinx.serialization.KSerializer<QFieldType> {
|
||||
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)
|
||||
}
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+4
-5
@@ -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<QFieldMetaData>? = null,
|
||||
val recordListFields: List<QFieldMetaData>? = null,
|
||||
val helpContents: List<QHelpContent>? = null,
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
}
|
||||
+4
-5
@@ -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<String> = emptySet(),
|
||||
val contentAsText: String? = null
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
}
|
||||
+4
-5
@@ -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,
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
}
|
||||
+4
-6
@@ -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<String, QTableMetaData>? = emptyMap(),
|
||||
val processes: Map<String, QProcessMetaData>? = emptyMap(),
|
||||
// todo val reports: Map<String, Any>?, // todo
|
||||
val widgets: Map<String, QWidgetMetaData>? = emptyMap(),
|
||||
val apps: Map<String, QAppMetaData>? = emptyMap(),
|
||||
val appTree: List<QAppTreeNode> = emptyList(),
|
||||
val branding: QBrandingMetaData? = QBrandingMetaData(),
|
||||
val environmentValues: Map<String, String?> = emptyMap(),
|
||||
val helpContent: Map<String, List<QHelpContent>>? = emptyMap(),
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
+4
-5
@@ -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<QFrontendStepMetaData>? = emptyList(),
|
||||
val hasPermission: Boolean = false,
|
||||
val stepFlow: String = "LINEAR",
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
+4
-5
@@ -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
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
+4
-6
@@ -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
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
+4
-6
@@ -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
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
+11
-7
@@ -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,
|
||||
|
||||
+10
-10
@@ -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>(BaseAuthenticationMetaData::class)
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
override fun selectDeserializer(element: JsonElement) = when (element.jsonObject["type"]?.jsonPrimitive?.content)
|
||||
{
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
object AuthenticationSerializer : JsonContentPolymorphicSerializer<BaseAuthenticationMetaData>(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<BaseAuthentic
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve an unknown authentication discriminator so unsupported methods fail explicitly in the UI.
|
||||
*******************************************************************************/
|
||||
@kotlinx.serialization.Serializable
|
||||
data class UnsupportedAuthenticationMetaData(override val type: String, override val name: String = "Unsupported authentication") : BaseAuthenticationMetaData
|
||||
+4
-5
@@ -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.authentication
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@Serializable(with = AuthenticationSerializer::class)
|
||||
sealed interface BaseAuthenticationMetaData
|
||||
{
|
||||
sealed interface BaseAuthenticationMetaData {
|
||||
val type: String;
|
||||
val name: String;
|
||||
|
||||
|
||||
+8
-7
@@ -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,19 +23,19 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata.authentication
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class FullyAnonymousAuthenticationMetaData(
|
||||
override val name: String
|
||||
) : BaseAuthenticationMetaData
|
||||
{
|
||||
) : BaseAuthenticationMetaData {
|
||||
override val type: String
|
||||
get() = TYPE;
|
||||
|
||||
companion object
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Keep the anonymous authentication discriminator aligned with polymorphic server metadata.
|
||||
*******************************************************************************/
|
||||
companion object {
|
||||
const val TYPE = "FULLY_ANONYMOUS"
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -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.authentication
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class ManageSessionRequest(
|
||||
val accessToken: String? = null,
|
||||
@@ -34,10 +34,14 @@ data class ManageSessionRequest(
|
||||
val redirectUri: String? = null,
|
||||
val authenticationFlow: String? = null,
|
||||
val expectedNonce: String? = null
|
||||
)
|
||||
{
|
||||
) {
|
||||
/*******************************************************************************
|
||||
** Redact authentication material so incidental object rendering cannot expose credentials.
|
||||
*******************************************************************************/
|
||||
override fun toString(): String = "ManageSessionRequest(credentials redacted)"
|
||||
}
|
||||
|
||||
/** AppAuth generates unpadded base64url; bound the semantic nonce before any code exchange. */
|
||||
/*******************************************************************************
|
||||
** AppAuth generates unpadded base64url; bound the semantic nonce before any code exchange.
|
||||
*******************************************************************************/
|
||||
fun isValidExpectedNonce(value: String?): Boolean = value != null && Regex("[A-Za-z0-9_-]{16,512}").matches(value)
|
||||
+4
-5
@@ -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,13 +24,11 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata.authentication
|
||||
import com.kingsrook.qqq.frontend.android.core.model.MapOfStringAny
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class ManageSessionResponse(
|
||||
val uuid: String? = null,
|
||||
val values: MapOfStringAny = emptyMap(),
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
+16
-5
@@ -3,18 +3,29 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata.authentication
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** An explicit request for optional provider logout; ordinary logout retains its existing contract. */
|
||||
/*******************************************************************************
|
||||
** An explicit request for optional provider logout; ordinary logout retains its existing contract.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class NativeLogoutRequest(val nativeLogout: NativeLogout)
|
||||
|
||||
/*******************************************************************************
|
||||
** Send only the explicitly selected registered callback when requesting company sign-out.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class NativeLogout(val postLogoutRedirectUri: String)
|
||||
|
||||
/** Secret-bearing response: deliberately no data-class toString/copy/component methods. */
|
||||
/*******************************************************************************
|
||||
** Secret-bearing response: deliberately no data-class toString/copy/component methods.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
class NativeLogoutResponse(val nativeLogoutData: NativeLogoutData? = null)
|
||||
|
||||
/** Transient original ID-token hint. Never use its claims as identity or persist/log this object. */
|
||||
/*******************************************************************************
|
||||
** Transient original ID-token hint. Never use its claims as identity or persist/log this object.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
class NativeLogoutData(val issuer: String, val endSessionEndpoint: String,
|
||||
val postLogoutRedirectUri: String, val idTokenHint: String)
|
||||
class NativeLogoutData(
|
||||
val issuer: String, val endSessionEndpoint: String,
|
||||
val postLogoutRedirectUri: String, val idTokenHint: String
|
||||
)
|
||||
+25
-9
@@ -3,30 +3,46 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata.authentication
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** UI API OAuth2 configuration. Native bearer and native stored-session grants are distinct. */
|
||||
/*******************************************************************************
|
||||
** UI API OAuth2 configuration. Native bearer and native stored-session grants are distinct.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class OAuth2AuthenticationMetaData(
|
||||
override val name: String,
|
||||
override val type: String = TYPE,
|
||||
val values: Values
|
||||
) : BaseAuthenticationMetaData
|
||||
{
|
||||
) : BaseAuthenticationMetaData {
|
||||
/*******************************************************************************
|
||||
** Keep public OAuth client settings separate from optional stored-session login and logout capabilities.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class Values(val clientId: String = "", val baseUrl: String = "", val scopes: String = "",
|
||||
data class Values(
|
||||
val clientId: String = "", val baseUrl: String = "", val scopes: String = "",
|
||||
val nativeClientId: String? = null, val nativeClientBaseUrl: String? = null,
|
||||
val bearerTokensAccepted: Boolean = false, val nativeSessionLogin: NativeSessionLogin? = null,
|
||||
val nativeSessionLogout: NativeSessionLogout? = null)
|
||||
val nativeSessionLogout: NativeSessionLogout? = null
|
||||
)
|
||||
|
||||
/*******************************************************************************
|
||||
** Carry the advertised PKCE, issuer, callback and nonce policy that gates native browser login.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class NativeSessionLogin(val flow: String = "", val codeChallengeMethod: String = "",
|
||||
data class NativeSessionLogin(
|
||||
val flow: String = "", val codeChallengeMethod: String = "",
|
||||
val issuer: String = "", val discoveryUrl: String = "", val redirectUris: List<String> = 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<String> = 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"
|
||||
|
||||
+8
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -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<QFrontendStepMetaData>? = null,
|
||||
|
||||
+24
-5
@@ -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;
|
||||
}
|
||||
+7
-10
@@ -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>(ProcessStepResult::class)
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
override fun selectDeserializer(element: JsonElement) = when (element.jsonObject["type"]?.jsonPrimitive?.content)
|
||||
{
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
class ProcessStepResultSerializer : JsonContentPolymorphicSerializer<ProcessStepResult>(ProcessStepResult::class) {
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
override fun selectDeserializer(element: JsonElement) = when (element.jsonObject["type"]?.jsonPrimitive?.content) {
|
||||
"COMPLETE" -> QJobComplete.serializer()
|
||||
"JOB_STARTED" -> QJobStarted.serializer()
|
||||
"RUNNING" -> QJobRunning.serializer()
|
||||
|
||||
+23
-3
@@ -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,
|
||||
|
||||
+23
-3
@@ -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,
|
||||
|
||||
+23
-3
@@ -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,
|
||||
|
||||
+23
-3
@@ -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,
|
||||
|
||||
+42
-26
@@ -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<WidgetBlock> = emptyList(),
|
||||
)
|
||||
{
|
||||
companion object
|
||||
{
|
||||
fun fromMap(param: Any?): WidgetBlock?
|
||||
{
|
||||
if(param is Map<*, *>)
|
||||
{
|
||||
fun mapGetOrElse(map: Map<String, Any?>, 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<String, Any?> = param as Map<String, Any?>
|
||||
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<String, Any>()
|
||||
/*******************************************************************************
|
||||
** 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<String, Any?>()
|
||||
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<String, Any>()
|
||||
val styles = copyStringMap(map["styles"])
|
||||
val values = copyStringMap(map["values"])
|
||||
|
||||
val mapSubBlocks = map["subBlocks"]
|
||||
|
||||
var subBlocks = emptyList<WidgetBlock>()
|
||||
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)
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
+79
-49
@@ -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"))
|
||||
} }
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-33
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-16
@@ -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<BaseAuthenticationMetaData>("""{"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<UiApiVersion>(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"}"""))
|
||||
|
||||
+24
-6
@@ -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")
|
||||
|
||||
+56
-24
@@ -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<UiApiVersion>(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<AdornmentType>("\"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"))
|
||||
|
||||
+41
-23
@@ -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<String, @Serializable(with = AnyValueSerializer::class) Any?>
|
||||
)
|
||||
|
||||
/***************************************************************************
|
||||
/*******************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
*******************************************************************************/
|
||||
@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<QJobComplete>(payload)
|
||||
assertEquals(mapOf("type" to "weight", "value" to 12L), decoded.values["weight"])
|
||||
@@ -80,12 +100,11 @@ class GenericSerializingTest
|
||||
assertEquals(decoded, roundTrip)
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
/*******************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun testQFrontendComponent()
|
||||
{
|
||||
fun testQFrontendComponent() {
|
||||
val component = Json.decodeFromString<QFrontendComponent>(
|
||||
"""
|
||||
{
|
||||
@@ -102,12 +121,11 @@ class GenericSerializingTest
|
||||
assertEquals(1, component.values.size)
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
/*******************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun testQJobCompleteWithArray()
|
||||
{
|
||||
fun testQJobCompleteWithArray() {
|
||||
val jobComplete = Json.decodeFromString<QJobComplete>(
|
||||
"""
|
||||
{
|
||||
|
||||
+19
-10
@@ -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<BaseAuthenticationMetaData>("""{"name":"users","type":"TABLE_BASED","values":{}}""")
|
||||
assertTrue(metadata is TableBasedAuthenticationMetaData)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun terminalProcessCompletionMayOmitNextStep()
|
||||
{
|
||||
fun terminalProcessCompletionMayOmitNextStep() {
|
||||
val result = json.decodeFromString<ProcessStepResult>("""{"type":"COMPLETE","processUUID":"test-process","values":{}}""")
|
||||
assertEquals("", (result as QJobComplete).nextStep)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun rendersContractCapturedFromTheLiveDemo()
|
||||
{
|
||||
fun rendersContractCapturedFromTheLiveDemo() {
|
||||
val metadata = json.decodeFromString<QInstance>(fixture("metadata"))
|
||||
assertTrue(metadata.apps!!.containsKey("mobileScanApp"))
|
||||
val process = json.decodeFromString<QProcessMetaData>(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() }
|
||||
}
|
||||
+9
-9
@@ -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())
|
||||
|
||||
|
||||
+9
-9
@@ -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,19 +25,18 @@ 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",
|
||||
|
||||
+36
-11
@@ -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>(), widgetBlock.subBlocks)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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")))
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
+11
-10
@@ -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,19 +27,19 @@ 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)
|
||||
|
||||
+67
-66
@@ -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<QInstance>)?.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)
|
||||
|
||||
+7
-10
@@ -1,4 +1,5 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2024-2024. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
@@ -24,18 +25,14 @@ 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()
|
||||
}
|
||||
|
||||
+8
-7
@@ -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<Environment>
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -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,11 +25,9 @@ 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)
|
||||
|
||||
+9
-13
@@ -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<Preferences>
|
||||
{
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
class MockPreferencesDataStore : DataStore<Preferences> {
|
||||
var preferences: Preferences = preferencesOf().toMutablePreferences()
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
override val data: Flow<Preferences>
|
||||
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
|
||||
}
|
||||
|
||||
+23
-37
@@ -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,69 +36,54 @@ val SESSION_USER_FULL_NAME_KEY = stringPreferencesKey("session_user_full_name")
|
||||
|
||||
val ENVIRONMENT_JSON_KEY = stringPreferencesKey("environment_json")
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
class SettingsRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
)
|
||||
{
|
||||
) {
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
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)
|
||||
}
|
||||
|
||||
+12
-4
@@ -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 }
|
||||
+88
-78
@@ -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<QInstance>)?.value
|
||||
val processName = if(entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().name else null
|
||||
val appName = if(entry.destination.hasRoute<AppRoute>()) entry.toRoute<AppRoute>().name else
|
||||
if(entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().appName else null
|
||||
val processName = if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().name else null
|
||||
val appName = if (entry.destination.hasRoute<AppRoute>()) entry.toRoute<AppRoute>().name else
|
||||
if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().appName else null
|
||||
atHome = entry.destination.hasRoute<HomeRoute>()
|
||||
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<String>("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<QInstance>).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<HomeRoute>(inclusive = false) && !atHome)
|
||||
{
|
||||
if (!controller.popBackStack<HomeRoute>(inclusive = false) && !atHome) {
|
||||
controller.navigate(HomeRoute) { popUpTo(controller.graph.id) { inclusive = true } }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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,12 +28,11 @@ 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 //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
|
||||
+27
-37
@@ -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<Boolean>)
|
||||
{
|
||||
fun NotYetImplementedDialog(title: String, body: String, isOpen: MutableState<Boolean>) {
|
||||
AlertDialog(
|
||||
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = "Info") },
|
||||
title = { Text(title) },
|
||||
|
||||
+25
-9
@@ -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<Credentials, AuthenticationException>)
|
||||
|
||||
/*******************************************************************************
|
||||
** Request provider sign-out through the platform browser boundary without recreating a QQQ session.
|
||||
*******************************************************************************/
|
||||
fun logout(context: Context, account: Auth0, scheme: String, callback: Callback<Void?, AuthenticationException>)
|
||||
}
|
||||
|
||||
val LocalAuth0Browser = staticCompositionLocalOf<Auth0Browser> {
|
||||
object : Auth0Browser
|
||||
{
|
||||
override fun login(context: Context, account: Auth0, audience: String, scheme: String, callback: Callback<Credentials, AuthenticationException>)
|
||||
{ WebAuthProvider.login(account).withAudience(audience).withScheme(scheme).start(context, callback) }
|
||||
override fun logout(context: Context, account: Auth0, scheme: String, callback: Callback<Void?, AuthenticationException>)
|
||||
{ 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<Credentials, AuthenticationException>) {
|
||||
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<Void?, AuthenticationException>) {
|
||||
WebAuthProvider.logout(account).withScheme(scheme).start(context, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-25
@@ -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 &&
|
||||
|
||||
+43
-18
@@ -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
|
||||
}
|
||||
}
|
||||
+11
-8
@@ -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()
|
||||
|
||||
+27
-22
@@ -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<Credentials, AuthenticationException>
|
||||
{
|
||||
override fun onFailure(error: AuthenticationException)
|
||||
{
|
||||
if (attempt != null) {
|
||||
val callback = object : Callback<Credentials, AuthenticationException> {
|
||||
/*******************************************************************************
|
||||
** 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)
|
||||
}
|
||||
+26
-12
@@ -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<Void?, AuthenticationException>
|
||||
{
|
||||
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<Void?, AuthenticationException> {
|
||||
/*******************************************************************************
|
||||
** 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") }
|
||||
|
||||
+11
-17
@@ -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."))
|
||||
}
|
||||
}
|
||||
|
||||
+7
-12
@@ -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<BaseAuthenticationMetaData>).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)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+49
-46
@@ -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<suspend (OAuth2LoginBinding) -> 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
|
||||
|
||||
+30
-22
@@ -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") }
|
||||
}
|
||||
}
|
||||
+14
-10
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -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))
|
||||
|
||||
+6
-6
@@ -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)
|
||||
content = content
|
||||
)
|
||||
}
|
||||
+25
-20
@@ -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<Boolean>)
|
||||
{
|
||||
fun MenuDialog(qViewModel: QViewModel, appTitle: String, isMenuDialogOpen: MutableState<Boolean>) {
|
||||
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") } })
|
||||
}
|
||||
+58
-42
@@ -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") }
|
||||
}
|
||||
})
|
||||
}
|
||||
+12
-7
@@ -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<Long>()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
+4
-4
@@ -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 = { },
|
||||
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user