fix(mobile): align profile routing, catalog timeout and replay cancellation

This commit is contained in:
yilsem committed 2026-10-07 20:23:17 +03:00
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,