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:
james.maesandCodex committed 2026-11-01 11:38:09 -06:00
1 parent 1127e4e4fa
commit 625a275de3
236 files changed
+8736 -5485

No files matched your search

+2
View File
@@ -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"
+15
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<project version="4">
<component name="ProjectCodeStyleConfiguration">
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</component>
</project>
+2
View File
@@ -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.
+2
View File
@@ -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.
+32
View File
@@ -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.
+18
View File
@@ -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.
+8
View File
@@ -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.
+6
View File
@@ -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.
+21
View File
@@ -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)
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2024-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -26,21 +27,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import org.junit.Assert.assertEquals
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
/*******************************************************************************
** Instrumented test, which will execute on an Android device.
** See [testing documentation](http://d.android.com/tools/testing).
*******************************************************************************/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest
{
class ExampleInstrumentedTest {
/*******************************************************************************
**
*******************************************************************************/
@Test
fun useAppContext()
{
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.kingsrook.qqq.frontend.android.core.test", appContext.packageName)
}
}
}
@@ -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()
}
/***************************************************************************
**
***************************************************************************/
/*******************************************************************************
*******************************************************************************/
}
@@ -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
@@ -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
}
@@ -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
}
@@ -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.")
}
}
@@ -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
}
}
@@ -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,
)
{
}
) {
}
@@ -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))
}
@@ -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
)
{
) {
}
@@ -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)
}
@@ -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()
)
{
) {
}
@@ -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()
)
{
) {
}
@@ -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)
}
@@ -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)
}
@@ -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(),
)
{
) {
}
@@ -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,
)
{
) {
}
@@ -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)
}
@@ -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,
@@ -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)
}
@@ -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,
@@ -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,
)
{
) {
}
@@ -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
)
{
) {
}
@@ -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,
)
{
) {
}
@@ -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(),
)
{
}
) {
}
@@ -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",
)
{
}
) {
}
@@ -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
)
{
}
) {
}
@@ -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
)
{
}
) {
}
@@ -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
)
{
}
) {
}
@@ -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,
@@ -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
@@ -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;
@@ -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"
}
}
}
@@ -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)
@@ -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(),
)
{
) {
}
@@ -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
)
@@ -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"
@@ -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"
}
}
@@ -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,
@@ -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;
}
}
@@ -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()
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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)
@@ -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)
}
}
@@ -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"))
} }
}
}
}
@@ -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"))
}
}
}
}
@@ -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"}"""))
@@ -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")
@@ -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"))
@@ -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,17 +100,16 @@ class GenericSerializingTest
assertEquals(decoded, roundTrip)
}
/***************************************************************************
/*******************************************************************************
**
***************************************************************************/
*******************************************************************************/
@Test
fun testQFrontendComponent()
{
fun testQFrontendComponent() {
val component = Json.decodeFromString<QFrontendComponent>(
"""
{
"type": "HELP_TEXT",
"values":
"values":
{
"helpText": "Lorem ipsum"
}
@@ -102,21 +121,20 @@ class GenericSerializingTest
assertEquals(1, component.values.size)
}
/***************************************************************************
/*******************************************************************************
**
***************************************************************************/
*******************************************************************************/
@Test
fun testQJobCompleteWithArray()
{
fun testQJobCompleteWithArray() {
val jobComplete = Json.decodeFromString<QJobComplete>(
"""
{
"processUUID": "123-123-123-123",
"nextStep": "Underpants",
"values":
"values":
{
"someString": "Nylon",
"someArray":
"someArray":
[
{"foo": "bar", "one": 1}
],
@@ -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() }
}
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2004-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -22,20 +23,19 @@
package com.kingsrook.qqq.frontend.android.core.model.metadata
import org.junit.Assert.*
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/***************************************************************************
/*******************************************************************************
**
***************************************************************************/
class QAppSectionTest
{
/***************************************************************************
*******************************************************************************/
class QAppSectionTest {
/*******************************************************************************
**
***************************************************************************/
*******************************************************************************/
@Test
fun testHasContent()
{
fun testHasContent() {
assertFalse(QAppSection("test", "Test").hasContent())
assertFalse(QAppSection("test", "Test", tables = null, processes = null, reports = null).hasContent())
@@ -45,4 +45,4 @@ class QAppSectionTest
assertTrue(QAppSection("test", "Test", reports = listOf("myReport")).hasContent())
assertTrue(QAppSection("test", "Test", tables = listOf("myTable"), processes = listOf("myProcess"), reports = listOf("myReport")).hasContent())
}
}
}
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2004-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -24,24 +25,23 @@ package com.kingsrook.qqq.frontend.android.core.model.metadata.authentication
import kotlinx.serialization.json.Json
import org.junit.Test
import org.junit.Assert.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
/***************************************************************************
/*******************************************************************************
**
***************************************************************************/
class AuthenticationSerializerTest
{
/***************************************************************************
*******************************************************************************/
class AuthenticationSerializerTest {
/*******************************************************************************
**
***************************************************************************/
*******************************************************************************/
@Test
fun testDecodeAuth0()
{
fun testDecodeAuth0() {
val json = """
{
"name":"auth0",
"type":"AUTH_0",
"values":
"values":
{
"baseUrl":"https://auth.coldtrack-dev.com/",
"clientId":"Q7hjPeDrfq11EgZyFxpoXQRTPhTn3KzN",
@@ -55,4 +55,4 @@ class AuthenticationSerializerTest
assertTrue(parsedItem is Auth0AuthenticationMetaData)
assertEquals("Q7hjPeDrfq11EgZyFxpoXQRTPhTn3KzN", (parsedItem as Auth0AuthenticationMetaData).values.clientId)
}
}
}
@@ -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")))
}
}
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2024-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -23,17 +24,15 @@ package com.kingsrook.qqq.frontend.android.core.utils
import org.junit.Assert.assertEquals
import org.junit.Test
/***************************************************************************
/*******************************************************************************
**
***************************************************************************/
class StringExtensionsTest
{
/***************************************************************************
*******************************************************************************/
class StringExtensionsTest {
/*******************************************************************************
**
***************************************************************************/
*******************************************************************************/
@Test
fun testFirstN()
{
fun testFirstN() {
assertEquals("", "".firstN(0));
assertEquals("", "".firstN(1));
assertEquals("", "".firstN(100));
@@ -46,4 +45,4 @@ class StringExtensionsTest
assertEquals("A", "ABC".firstN(1));
assertEquals("ABC", "ABC".firstN(100));
}
}
}
+21
View File
@@ -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)
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2024-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -26,21 +27,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import org.junit.Assert.assertEquals
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
/*******************************************************************************
** Instrumented test, which will execute on an Android device.
** See [testing documentation](http://d.android.com/tools/testing).
*******************************************************************************/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest
{
class ExampleInstrumentedTest {
/*******************************************************************************
**
*******************************************************************************/
@Test
fun useAppContext()
{
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.kingsrook.qqq.frontend.android.mobileapp.test", appContext.packageName)
}
}
}
@@ -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)
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2024-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -24,19 +25,15 @@ import android.app.Application
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
import com.kingsrook.qqq.frontend.android.mobileapp.container.QDefaultAppContainer
/***************************************************************************
**
***************************************************************************/
class QMobileApplication : Application()
{
/*******************************************************************************
*******************************************************************************/
class QMobileApplication : Application() {
lateinit var container: QAppContainer
/***************************************************************************
**
***************************************************************************/
override fun onCreate()
{
/*******************************************************************************
*******************************************************************************/
override fun onCreate() {
super.onCreate()
container = QDefaultAppContainer()
}
}
}
@@ -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>
}
}
}
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2024-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -24,14 +25,12 @@ import com.kingsrook.qqq.frontend.android.core.controllers.NetworkQQQRepository
import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer.Companion.availableEnvironments
/***************************************************************************
**
***************************************************************************/
class QDefaultAppContainer : QAppContainer
{
/*******************************************************************************
*******************************************************************************/
class QDefaultAppContainer : QAppContainer {
override val qqqRepository: QQQRepository by lazy()
{
NetworkQQQRepository(availableEnvironments[0].baseUrl)
}
}
}
@@ -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
}
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2024-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -35,72 +36,57 @@ val SESSION_USER_FULL_NAME_KEY = stringPreferencesKey("session_user_full_name")
val ENVIRONMENT_JSON_KEY = stringPreferencesKey("environment_json")
/***************************************************************************
**
***************************************************************************/
/*******************************************************************************
*******************************************************************************/
class SettingsRepository(
private val dataStore: DataStore<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)
}
}
}
}
@@ -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 }
@@ -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 } }
}
}
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2004-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -27,14 +28,13 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.QAppMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
import com.kingsrook.qqq.frontend.android.mobileapp.ui.QNavigator
/***************************************************************************
/*******************************************************************************
** screen that renders an app (e.g., a QQQAppMetaData)
***************************************************************************/
*******************************************************************************/
@Composable
fun QAppHome(qInstance: QInstance, modifier: Modifier = Modifier, app: QAppMetaData? = null, qNavigator: QNavigator? = null)
{
fun QAppHome(qInstance: QInstance, modifier: Modifier = Modifier, app: QAppMetaData? = null, qNavigator: QNavigator? = null) {
///////////////////////////////////////////////////////////////////////
// idk, in case we do other layouts in the future, simple layer here //
///////////////////////////////////////////////////////////////////////
QAppHomeGridLayout(qInstance, modifier, app, qNavigator)
}
}
@@ -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) },
@@ -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)
}
}
}
@@ -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 &&
@@ -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
}
}
@@ -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()
@@ -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)
}
@@ -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") }
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2004-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -28,37 +29,30 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
/***************************************************************************
/*******************************************************************************
** class to work with auth0
***************************************************************************/
object QAuth0Service
{
*******************************************************************************/
object QAuth0Service {
lateinit var scheme: String
private const val TAG = "QAuth0Service"
/***************************************************************************
**
***************************************************************************/
fun makeAuth0Instance(qViewModel: QViewModel): Auth0
{
if(!::scheme.isInitialized)
{
/*******************************************************************************
*******************************************************************************/
fun makeAuth0Instance(qViewModel: QViewModel): Auth0 {
if (!::scheme.isInitialized) {
throw (IllegalStateException("QAuth0Service.scheme was not initialized!"))
}
when (val loadState = qViewModel.authenticationMetaDataLoadState)
{
is LoadState.Success ->
{
when (val loadState = qViewModel.authenticationMetaDataLoadState) {
is LoadState.Success -> {
val authenticationMetaData = loadState.value as Auth0AuthenticationMetaData
val auth0Instance: Auth0 = Auth0.getInstance(authenticationMetaData.values.clientId, authenticationMetaData.values.baseUrl)
Log.d(TAG, "clientId: ${auth0Instance.clientId}, domain: ${auth0Instance.domain}")
return auth0Instance
}
else ->
{
else -> {
throw (IllegalStateException("authenticationMetaData is not loaded."))
}
}
@@ -66,4 +60,4 @@ object QAuth0Service
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
@@ -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") }
}
}
@@ -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.")
}
}
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2024-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -30,12 +31,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/***************************************************************************
**
***************************************************************************/
/*******************************************************************************
*******************************************************************************/
@Composable
fun FullScreenLoading(loadingText: String, modifier: Modifier = Modifier)
{
fun FullScreenLoading(loadingText: String, modifier: Modifier = Modifier) {
FullSizedAllCenteredColumn(modifier)
{
Text(loadingText, color = Color.Gray, modifier = Modifier.padding(bottom = 16.dp))
@@ -46,4 +45,4 @@ fun FullScreenLoading(loadingText: String, modifier: Modifier = Modifier)
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
@@ -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
)
}
@@ -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") } })
}
@@ -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") }
}
})
}
@@ -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()
}
@@ -1,4 +1,5 @@
/*
* SPDX-License-Identifier: Apache-2.0
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2004-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
@@ -41,11 +42,10 @@ import androidx.compose.ui.unit.sp
import com.kingsrook.qqq.frontend.android.mobileapp.R
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
/***************************************************************************
**
***************************************************************************/
/*******************************************************************************
*******************************************************************************/
@Composable
fun NoNetworkDialog(qViewModel: QViewModel)
fun NoNetworkDialog(@Suppress("UNUSED_PARAMETER") qViewModel: QViewModel) // The caller observes connectivity; retain this published dialog signature for existing hosts.
{
AlertDialog(
title = { },
@@ -72,4 +72,4 @@ fun NoNetworkDialog(qViewModel: QViewModel)
dismissButton = { },
confirmButton = { },
)
}
}
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more