fix(mobile): align profile routing, catalog timeout and replay cancellation
This commit is contained in:
1 parent
827c74b3c3
commit
cb7e6acb59
21 files changed
+297
-13
No files matched your search
@@ -46,6 +46,8 @@ object AiturkCompat {
|
||||
private const val FALLBACK_FEATURE_UNAVAILABLE = "This feature is not available in this version of AITURK IDE."
|
||||
private const val FALLBACK_PHONE_FORBIDDEN =
|
||||
"For security this cannot be done from the phone. Do it in AITURK IDE on your computer."
|
||||
private const val FALLBACK_BACKEND_NOT_READY =
|
||||
"The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes."
|
||||
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
@@ -76,6 +78,10 @@ object AiturkCompat {
|
||||
fun isLinked(): Boolean =
|
||||
linkedOverride ?: runCatching { AiturkLanTrust.isPairedUrl(AuthManager.baseUrl()) }.getOrDefault(false)
|
||||
|
||||
/** REST and chat must resolve the same phone-selected profile in the IDE proxy. */
|
||||
fun webSocketProfile(profile: String?): Map<String, String> =
|
||||
if (isLinked()) mapOf("profile" to (profile?.takeIf { it.isNotBlank() } ?: "default")) else emptyMap()
|
||||
|
||||
// ── JSON-RPC ─────────────────────────────────────────────────────────
|
||||
|
||||
/** True when an awaited call's error must not also be shown as a chat banner. */
|
||||
@@ -135,12 +141,14 @@ object AiturkCompat {
|
||||
code: Int,
|
||||
errorBody: String?,
|
||||
): String? {
|
||||
if (code != HTTP_FORBIDDEN && code != HTTP_NOT_FOUND) return null
|
||||
if (code != HTTP_FORBIDDEN && code != HTTP_NOT_FOUND && code != HTTP_UNAVAILABLE) return null
|
||||
if (!isLinked()) return null
|
||||
val body = errorBody?.let { runCatching { Json.parseToJsonElement(it) as? JsonObject }.getOrNull() }
|
||||
val error = (body?.get("error") as? JsonPrimitive)?.content
|
||||
val detail = (body?.get("detail") as? JsonPrimitive)?.content
|
||||
return when {
|
||||
code == HTTP_UNAVAILABLE && error == "backend_unavailable" ->
|
||||
string(R.string.aiturk_backend_not_ready) ?: FALLBACK_BACKEND_NOT_READY
|
||||
code == HTTP_FORBIDDEN && error == "forbidden" -> phoneForbiddenText()
|
||||
code == HTTP_NOT_FOUND && (error == "not_found" || detail == "Not Found") -> featureUnavailableText()
|
||||
else -> null
|
||||
@@ -201,4 +209,5 @@ object AiturkCompat {
|
||||
|
||||
private const val HTTP_FORBIDDEN = 403
|
||||
private const val HTTP_NOT_FOUND = 404
|
||||
private const val HTTP_UNAVAILABLE = 503
|
||||
}
|
||||
@@ -799,8 +799,10 @@ object AuthManager {
|
||||
val raw = serverStore.getLatestState().wsAuthParam
|
||||
val authParam = if (raw.isBlank()) "token" else raw
|
||||
return endpointForBuild().webSocketUrl(
|
||||
path = "api/ws",
|
||||
authParameter = authParam,
|
||||
credential = getToken().orEmpty(),
|
||||
extraParams = com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(activeProfileId.value),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ object ApiClient {
|
||||
.newBuilder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(ProfileScopeInterceptor)
|
||||
.addInterceptor(CatalogTimeoutInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.authenticator(TokenRefreshAuthenticator)
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Cold provider discovery can exceed the ordinary 30-second screen timeout. */
|
||||
object CatalogTimeoutInterceptor : Interceptor {
|
||||
const val TIMEOUT_MS = 120_000L
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val catalog = request.method == "GET" && request.url.encodedPath.endsWith("/api/model/options")
|
||||
val next = if (catalog) chain.withReadTimeout(TIMEOUT_MS.toInt(), TimeUnit.MILLISECONDS) else chain
|
||||
return next.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,8 @@ class KanbanEventsClient {
|
||||
path = KANBAN_EVENTS_PATH,
|
||||
authParameter = authParam,
|
||||
credential = credential,
|
||||
extraParams = mapOf("since" to cursor.toString(), "board" to board),
|
||||
extraParams = mapOf("since" to cursor.toString(), "board" to board) +
|
||||
com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(AuthManager.activeProfileId.value),
|
||||
)
|
||||
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.CatalogTimeoutInterceptor
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
@@ -19,7 +20,7 @@ class ModelOptionsRepository(
|
||||
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||
},
|
||||
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
||||
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
|
||||
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params, timeoutMs = CatalogTimeoutInterceptor.TIMEOUT_MS)
|
||||
},
|
||||
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
||||
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.ws
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
@@ -171,6 +172,10 @@ internal class ReplayTracker(
|
||||
emitEvent(finalEvent)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Disconnect/profile switch cancels replay; it is not a
|
||||
// remote failure and must not request a stale transcript.
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
onReplayFailure(sid, e)
|
||||
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||
|
||||
@@ -85,7 +85,7 @@ object MediaStreamResolver {
|
||||
openFileUriStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
uriString.startsWith("/") -> {
|
||||
File(uriString).isAbsolute -> {
|
||||
openLocalFileStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
@@ -168,8 +168,11 @@ object MediaStreamResolver {
|
||||
fallbackMime: String,
|
||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||
): T {
|
||||
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
|
||||
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
|
||||
val file =
|
||||
runCatching { File(java.net.URI(uriString)) }.getOrElse {
|
||||
val parsed = Uri.parse(uriString).path ?: throw IOException("Invalid file URI: $uriString")
|
||||
File(parsed)
|
||||
}
|
||||
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
|
||||
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
||||
return file.inputStream().use { inputStream ->
|
||||
|
||||
@@ -221,7 +221,8 @@ class CronJobsViewModel :
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.triggerCronJob(id) }
|
||||
// The server may have started the job even if its reply is lost.
|
||||
safeApiCall(retries = 0) { ApiClient.hermesApi.triggerCronJob(id) }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
@@ -466,7 +467,7 @@ class CronJobsViewModel :
|
||||
val result =
|
||||
withContext(ioDispatcher) {
|
||||
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
||||
safeApiCall {
|
||||
safeApiCall(retries = 0) {
|
||||
ApiClient.hermesApi.instantiateBlueprint(
|
||||
InstantiateBlueprintRequest(
|
||||
blueprint = editor.selectedBlueprintKey,
|
||||
@@ -540,7 +541,7 @@ class CronJobsViewModel :
|
||||
*/
|
||||
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
||||
val createdResult =
|
||||
safeApiCall {
|
||||
safeApiCall(retries = 0) {
|
||||
ApiClient.hermesApi.createCronJob(
|
||||
CreateCronJobRequest(
|
||||
name = editor.name,
|
||||
|
||||
Reference in new issue
Block a user