Compare commits

..
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 4467d8ec64 fix(core): allow 60 s for the metadata load and name the URL on timeout
android / android (push) Successful in 15m7s
build / android (push) Successful in 15m7s
GET /qqq/v1/metaData returns about 1.7 MB and takes the server 4 to 8 s to
build; the default 10 s read timeout surfaced as 'Error loading QInstance:
timeout' on handhelds. Only the metadata call gets the long limit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:01:07 -06:00
james.maesandClaude Sonnet 5.5 9208416fa4 test(android): cover string widget icons and summary-only help decoding
android / android (push) Failing after 3m21s
build / android (push) Failing after 3m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 07:20:39 -06:00
james.maesandClaude Sonnet 5.5 778ac58612 fix(android): accept string widget icons and summary-only help content from current QQQ core
android / android (push) Failing after 3m27s
build / android (push) Failing after 3m27s
Core serializes QWidgetMetaData.icon as a string and QHelpContent may carry only a summary; the strict models failed loading the whole instance and echoPrint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 06:19:19 -06:00
james.maes e8b6be00e8 Merge pull request 'fix: restore main (revert commit that deleted the whole tree)' (#23) from fix/restore-main-tree into main
android / android (push) Failing after 3m29s
build / android (push) Failing after 3m29s
2026-11-09 12:19:07 +00:00
7 changed files with 193 additions and 13 deletions

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 1.0.4 (versionCode 6)
- Loading the server's metadata no longer fails with "Error loading QInstance: timeout" on a handheld. The metadata response is about 1.7 MB and takes the server 4 to 8 seconds to build, and the default 10 second read timeout expired. That one call now allows 60 seconds; every other call keeps its short timeout. A timeout now reads "Timed out after 60.0s loading the server's metadata from <server>/qqq/v1/metaData".
- Includes 1.0.3 (versionCode 5): string widget icons and summary-only help content from current QQQ core are accepted. (PR 24)
## 1.0.2 (versionCode 4), candidate
Not yet built, signed, tagged or published (prepared 2026-11-06). Supersedes the unpublished signed 1.0.1 (versionCode 3) candidate and updates it in place with the same permanent signer.
@@ -44,18 +44,30 @@ import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import retrofit2.Retrofit
import java.io.IOException
import java.io.InterruptedIOException
import java.util.TimeZone
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/*******************************************************************************
** Ceiling for loading the server's metadata (GET /qqq/v1/metaData). That response is large (about 1.7 MB for a
** platform administrator on the staging server) and the server needs 4 to 8 seconds to build it, so the default
** 10 second OkHttp read timeout expired on handhelds. Only this one call gets the long limit; every other call
** keeps the short defaults.
*******************************************************************************/
const val METADATA_LOAD_TIMEOUT_SECONDS = 60L
/*******************************************************************************
*******************************************************************************/
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 metaDataTimeoutMillis: Long = METADATA_LOAD_TIMEOUT_SECONDS * 1000, 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()
private val metaDataApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
/*******************************************************************************
*******************************************************************************/
@@ -63,9 +75,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
baseUrl = environment.baseUrl
if (qqqApiServiceMap[baseUrl] == null) {
val thisRetrofit = buildRetrofit(baseUrl)
val thisQQQApiService = thisRetrofit.create(QQQApiService::class.java)
qqqApiServiceMap[baseUrl] = thisQQQApiService
val client = buildClient(baseUrl)
qqqApiServiceMap[baseUrl] = buildRetrofit(baseUrl, client).create(QQQApiService::class.java)
val metaDataClient = client.newBuilder()
.callTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
.readTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
.build()
metaDataApiServiceMap[baseUrl] = buildRetrofit(baseUrl, metaDataClient).create(QQQApiService::class.java)
}
// retrofit = buildRetrofit()
@@ -74,10 +90,19 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
/*******************************************************************************
*******************************************************************************/
private fun buildRetrofit(baseUrl: String): Retrofit {
private fun buildRetrofit(baseUrl: String, client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType()))
.client(
.client(client)
.baseUrl(baseUrl)
.build()
}
/*******************************************************************************
** Build the shared client (same-origin, session, compatibility and error interceptors) with default timeouts.
*******************************************************************************/
private fun buildClient(baseUrl: String): OkHttpClient {
return (
OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
@@ -110,8 +135,6 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
.addInterceptor(SameOriginInterceptor())
.build()
)
.baseUrl(baseUrl)
.build()
}
/*******************************************************************************
@@ -309,7 +332,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
override suspend fun getMetaData(
frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String
): QInstance {
return qqqApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion)
try {
return metaDataApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion)
} catch (e: InterruptedIOException) {
// OkHttp reports read and call timeouts as InterruptedIOException (SocketTimeoutException); the bare "timeout" is not diagnosable.
// Coroutine cancellation is a CancellationException, not an IOException, so it is unaffected.
throw IOException("Timed out after ${metaDataTimeoutMillis / 1000.0}s loading the server's metadata from ${baseUrl.trimEnd('/')}/qqq/v1/metaData", e)
}
}
/*******************************************************************************
@@ -27,8 +27,9 @@ import kotlinx.serialization.Serializable
*******************************************************************************/
@Serializable
data class QHelpContent(
val content: String,
val format: String,
val content: String? = null, // QQQ core now sends summary-only help entries
val summary: String? = null,
val format: String? = null,
val roles: Set<String> = emptySet(),
val contentAsText: String? = null
) {
@@ -23,6 +23,7 @@
package com.kingsrook.qqq.frontend.android.core.model.metadata
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/*******************************************************************************
*******************************************************************************/
@@ -30,7 +31,7 @@ import kotlinx.serialization.Serializable
data class QWidgetMetaData(
val name: String,
val label: String? = null,
val icon: QIcon? = null,
val icon: JsonElement? = null, // QQQ core serializes a widget icon as a plain string (e.g. "troubleshoot"); tolerate an object too
val type: String,
) {
}
@@ -303,4 +303,60 @@ class NetworkQQQRepositoryTest {
}
}
}
/*******************************************************************************
** The metadata response is large and slow to build; a response that starts after the old 10 second read timeout must still load.
*******************************************************************************/
@Test
fun metaDataLoadSurvivesAServerThatAnswersAfterTheOldDefaultTimeout() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setHeadersDelay(11, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"tables":{},"processes":{},"widgets":{},"apps":{}}"""))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin))
val instance = repository.getMetaData("qqq-android", "1.0", "app", "1.0")
assertTrue(instance.tables!!.isEmpty())
server.takeRequest()
assertEquals("/qqq/v1/metaData", server.takeRequest().requestUrl!!.encodedPath)
}
}
/*******************************************************************************
** A metadata timeout names what timed out and the URL, without any query values, tokens or cookies.
*******************************************************************************/
@Test
fun metaDataTimeoutNamesTheMetadataLoadAndUrlWithoutSecrets() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setHeadersDelay(3, java.util.concurrent.TimeUnit.SECONDS).setBody("{}"))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin, metaDataTimeoutMillis = 500)
repository.setEnvironment(Environment("test", origin))
repository.setSessionUUID("fixture-session-value")
val failure = runCatching { repository.getMetaData("qqq-android", "1.0", "app", "1.0") }.exceptionOrNull()
assertTrue(failure is java.io.IOException)
val message = failure!!.message!!
assertTrue(message, message.startsWith("Timed out after 0.5s loading the server's metadata from "))
assertTrue(message, message.endsWith(origin.trimEnd('/') + "/qqq/v1/metaData"))
assertFalse(message.contains("fixture-session-value"))
assertFalse(message.contains("?"))
}
}
/*******************************************************************************
** Only metadata gets the long limit: other calls keep the short default and still fail fast.
*******************************************************************************/
@Test
fun otherCallsStillUseTheShortDefaultTimeout() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setHeadersDelay(12, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"records":[]}"""))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin))
val failure = runCatching { repository.queryRecords("items", "id", 0, 10) }.exceptionOrNull()
assertTrue(failure is java.io.InterruptedIOException)
}
}
}
@@ -0,0 +1,88 @@
/*
* 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
* 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.metadata
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/*******************************************************************************
** Current QQQ core serializes widget icons as plain strings and sends
** summary-only help entries; the decoder must accept those shapes and the
** older object/content shapes.
*******************************************************************************/
class MetadataDecodingTest {
private val json = Json { ignoreUnknownKeys = true }
/*******************************************************************************
**
*******************************************************************************/
@Test
fun widgetIconAsStringDecodes() {
val widget = json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table","icon":"troubleshoot"}""")
assertEquals(JsonPrimitive("troubleshoot"), widget.icon)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun widgetIconAsObjectStillDecodes() {
val widget = json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table","icon":{"name":"troubleshoot"}}""")
assertTrue(widget.icon is JsonObject)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun widgetWithoutIconDecodes() {
assertNull(json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table"}""").icon)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun summaryOnlyHelpDecodes() {
val help = json.decodeFromString<QHelpContent>("""{"summary":"short help","roles":["ALL_SCREENS"]}""")
assertEquals("short help", help.summary)
assertNull(help.content)
assertNull(help.format)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun contentHelpStillDecodes() {
val help = json.decodeFromString<QHelpContent>("""{"content":"body","format":"TEXT"}""")
assertEquals("body", help.content)
assertEquals("TEXT", help.format)
assertNull(help.summary)
}
}
@@ -128,7 +128,7 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro
OutlinedButton(onClick = { processViewModel.loadRemoteOptions(field) }) { Text("Retry choices") }
}
semanticFieldHint(field)?.let { Text(it) }
field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: plainText(it.content)) }
field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: it.content?.let { c -> plainText(c) } ?: it.summary.orEmpty()) }
}
}
}