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

+23
View File
@@ -0,0 +1,23 @@
name: Android acceptance
on:
push:
branches: [main]
workflow_dispatch:
jobs:
android:
runs-on: windows-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
with:
github-server-url: https://gitea.twinpay.one
- name: Test and build exact commit
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
. "$env:LOCALAPPDATA\aiturk-mobile-tools\ortam.ps1"
if ((git rev-parse HEAD).Trim() -ne $env:GITHUB_SHA) { throw 'Checkout SHA mismatch' }
.\gradlew.bat testDebugUnitTest assembleDebug compileReleaseKotlin --no-daemon --console=plain
exit $LASTEXITCODE
+5
View File
@@ -21,6 +21,7 @@ android {
applicationId = "online.turkservis.aiturk.mobil"
minSdk = 26
targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Version overrides passed from CI via -PversionName / -PversionCode
// Falls back to defaults for local development.
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
@@ -326,6 +327,10 @@ val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent
tasks.withType<Test>().configureEach {
useJUnitPlatform()
maxParallelForks = 2
// Source-level expectations use decimal dots; the developer's Windows
// display language must not change test output (the APK locale is unaffected).
systemProperty("user.language", "en")
systemProperty("user.country", "US")
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
if (!includeBenchmarks) {
filter { excludeTestsMatching("*Benchmark") }
@@ -0,0 +1,75 @@
package com.m57.hermescontrol.data.aiturk
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.runBlocking
import okhttp3.Call
import okhttp3.EventListener
import okhttp3.Handshake
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.util.concurrent.TimeUnit
/** Opt-in live Android/Conscrypt regression. Arguments contain no credentials. */
@RunWith(AndroidJUnit4::class)
class AiturkRelayDeviceTest {
@Test
fun pinnedHelloOverPublicRelay() {
val args = InstrumentationRegistry.getArguments()
val relayId = args.getString("aiturkRelayId")
val fingerprint = args.getString("aiturkFingerprint")
assumeTrue("Supply the IDE relay ID and public certificate fingerprint", relayId != null && fingerprint != null)
requireNotNull(relayId)
requireNotNull(fingerprint)
val started = System.nanoTime()
fun mark(stage: String) {
Log.i("AiturkRelayTest", "$stage ${TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)}ms")
}
val listener = object : EventListener() {
override fun secureConnectStart(call: Call) = mark("TLS start")
override fun secureConnectEnd(call: Call, handshake: Handshake?) = mark("TLS complete")
override fun requestHeadersEnd(call: Call, request: Request) = mark("HTTP sent")
override fun responseHeadersEnd(call: Call, response: Response) = mark("HTTP ${response.code}")
override fun callFailed(call: Call, ioe: IOException) = mark("FAILED ${ioe.javaClass.simpleName}")
}
val base = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.callTimeout(30, TimeUnit.SECONDS)
.eventListener(listener)
.build()
runBlocking { AuthManager.awaitInitialization() }
val hostPort = "$relayId.relay.aiturk.invalid:443"
val previousPin = AiturkLanTrust.pinFor(hostPort)
AiturkLanTrust.pin(hostPort, fingerprint)
val client = if (args.getString("aiturkProductionClient") == "true") {
OkHttpProvider.base.newBuilder().eventListener(listener).callTimeout(30, TimeUnit.SECONDS).build()
} else {
AiturkLanTrust.pinnedClient(base, fingerprint)
}
try {
repeat(2) {
client.newCall(Request.Builder().url("https://$relayId.relay.aiturk.invalid/aiturk/pair/hello").build())
.execute().use { response ->
assertEquals(200, response.code)
assertTrue(response.body.string().contains("aiturk-ide"))
}
mark("hello $it complete")
}
} finally {
client.connectionPool.evictAll()
client.dispatcher.executorService.shutdown()
if (previousPin == null) AiturkLanTrust.unpin(hostPort) else AiturkLanTrust.pin(hostPort, previousPin)
}
}
}
@@ -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,
+1
View File
@@ -1,4 +1,5 @@
<resources>
<string name="aiturk_backend_not_ready">Seçili profil AITURK IDE’de henüz hazır değil. Bilgisayarda IDE’yi açık tutun; kurulum tamamlanınca yeniden deneyin.</string>
<string name="app_name">AITURK Mobil</string>
<!-- Chat -->
+1
View File
@@ -1,4 +1,5 @@
<resources>
<string name="aiturk_backend_not_ready">The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes.</string>
<string name="app_name">AITURK Mobil</string>
<!-- Chat -->
@@ -42,6 +42,7 @@ import org.junit.jupiter.api.Test
class AiturkCompatTest {
private val unavailable = "Bu özellik AITURK IDE'nin bu sürümünde yok."
private val forbidden = "Güvenlik nedeniyle bu işlem telefondan yapılamaz."
private val backendNotReady = "Seçili profil IDE’de henüz hazır değil."
@BeforeEach
fun setUp() {
@@ -50,6 +51,7 @@ class AiturkCompatTest {
when (id) {
R.string.aiturk_feature_unavailable -> unavailable
R.string.aiturk_phone_forbidden -> forbidden
R.string.aiturk_backend_not_ready -> backendNotReady
else -> null
}
}
@@ -58,6 +60,24 @@ class AiturkCompatTest {
@AfterEach
fun tearDown() = AiturkCompat.resetForTest()
@Test
fun phoneProfileIsExplicitOnWebSocketsAndPlainHermesIsUnchanged() {
AiturkCompat.linkedOverride = true
assertEquals(mapOf("profile" to "default"), AiturkCompat.webSocketProfile(null))
assertEquals(mapOf("profile" to "kodlama"), AiturkCompat.webSocketProfile("kodlama"))
AiturkCompat.linkedOverride = false
assertTrue(AiturkCompat.webSocketProfile("kodlama").isEmpty())
}
@Test
fun unresolvedIdeBackendGetsAnActionableMessageWithoutChangingOtherServerErrors() {
AiturkCompat.linkedOverride = true
assertEquals(backendNotReady, AiturkCompat.httpErrorMessage(503, """{"error":"backend_unavailable"}"""))
assertNull(AiturkCompat.httpErrorMessage(503, """{"error":"different_failure"}"""))
AiturkCompat.linkedOverride = false
assertNull(AiturkCompat.httpErrorMessage(503, """{"error":"backend_unavailable"}"""))
}
private class Gateway(
scope: CoroutineScope,
) {
@@ -0,0 +1,36 @@
package com.m57.hermescontrol.data.remote
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.concurrent.TimeUnit
class CatalogTimeoutInterceptorTest {
@Test
fun `only catalog reads receive the discovery timeout including proxy prefixes`() {
for (path in listOf("/api/model/options", "/hermes/api/model/options", "/api/cron/jobs", "/api/model/options-extra")) {
var timeout = 0
val client =
OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(CatalogTimeoutInterceptor)
.addInterceptor { chain ->
timeout = chain.readTimeoutMillis()
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("{}".toResponseBody())
.build()
}
.build()
client.newCall(Request.Builder().url("https://test.invalid$path").build()).execute().close()
assertEquals(if (path.endsWith("/api/model/options")) 120_000 else 30_000, timeout)
}
}
}
@@ -98,9 +98,9 @@ class GatewayFileClientTest {
}
@Test
fun `normalizePath expands tilde`() {
val home = System.getenv("HOME") ?: "/home/test"
assertEquals("$home/foo.png", GatewayFileClient.normalizePath("~/foo.png"))
fun `normalizePath expands tilde only when home is known`() {
val home = System.getenv("HOME")
assertEquals(home?.let { "$it/foo.png" }, GatewayFileClient.normalizePath("~/foo.png"))
}
@Test
@@ -44,7 +44,7 @@ class RawRpcParamsRatchetTest {
rootDir
.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" }
.filter { !it.invariantSeparatorsPath.contains("data/ws/contract") && it.name != "WsMethods.kt" }
.forEach { file ->
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
val code = stripComments(file.readText())
@@ -27,6 +27,30 @@ class ReplayTrackerTest {
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
@Test
fun `cancelled replay does not report failure or request stale transcript`() = runTest {
val started = CompletableDeferred<Unit>()
var failures = 0
val events = mutableListOf<WsEvent>()
val tracker = ReplayTracker(
scope = this,
fetchEventsSince = {
started.complete(Unit)
kotlinx.coroutines.awaitCancellation()
},
emitEvent = { events.add(it) },
onReplayFailure = { _, _ -> failures++ },
)
tracker.setSeqWatermark("old-profile", 1)
tracker.triggerReplay()
started.await()
tracker.clear()
advanceUntilIdle()
assertEquals(0, failures)
assertTrue(events.isEmpty())
assertFalse(tracker.isReplayInFlight())
}
@Test
fun `initial state has empty watermarks and no replay in flight`() {
val testDispatcher = StandardTestDispatcher()
@@ -555,4 +555,30 @@ class CronJobsViewModelTest {
assertEquals("gateway unreachable", job.last_fire_error?.detail)
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
}
@Test
fun `lost create response does not create duplicate scheduled jobs`() {
val vm = createViewModel()
vm.openNewJobDialog()
settle()
vm.updateEditorField("name", "Retry safety")
vm.updateEditorField("schedule", "every 1d")
coEvery { mockApi.createCronJob(any()) } throws java.net.SocketTimeoutException("reply lost")
vm.saveEditor()
settle()
coVerify(exactly = 1) { mockApi.createCronJob(any()) }
assertTrue(vm.uiState.value.editorState.isOpen)
assertFalse(vm.uiState.value.editorState.isSaving)
assertNotNull(vm.uiState.value.editorState.toastMessage)
}
@Test
fun `lost trigger response does not run a scheduled job twice`() {
val vm = createViewModel()
coEvery { mockApi.triggerCronJob("j-test") } throws java.net.SocketTimeoutException("reply lost")
vm.triggerCronJob("j-test")
settle()
coVerify(exactly = 1) { mockApi.triggerCronJob("j-test") }
assertNotNull(vm.uiState.value.toastMessage)
}
}
+33
View File
@@ -0,0 +1,33 @@
# AITURK Mobil beta.5 candidate
Paired IDE REST, chat and Kanban sockets select the same phone profile. Requires
IDE beta.19 for the desktop routing/startup fix. An unavailable backend returns
an actionable Turkish message instead of waiting indefinitely.
Cold model discovery took 38 seconds on the actual backend. Only model catalog
REST/RPC reads now allow 120 seconds; ordinary requests keep their prior limit.
Cron create, blueprint creation and manual trigger are not replayed by the
application retry loop after an ambiguous network failure. This does not claim
server-side exactly-once execution or disable every transport-level retry.
Disconnect cancellation no longer reports replay as a remote error or asks for
the old transcript. Media streaming uses platform-absolute paths/file URIs.
Windows test portability: HOME absence, invariant path separators and an
explicit JVM test locale; APK localization is unchanged.
## Acceptance gates
- Targeted JVM tests pass, including every previously failing class, profile,
Cron, relay, catalog, replay cancellation and media tests.
- Gitea runs the complete JVM suite, debug build and release compilation on the
exact source commit before publication. No excluded failing test list.
- Opt-in Android public-relay test verifies TLS with the actual production
OkHttp stack. It uses only public relay ID/certificate fingerprint arguments.
- Desktop's opt-in real TLS proxy test covers screen reads, Cron record
create/update/pause/resume/delete and chat ping. No real agent run is triggered.
- Signed release, installed UI acceptance and physical-phone/cellular testing
remain separate gates, not implied by unit or hello tests.
Not included: bypassing IDE phone security restrictions, implementing missing
upstream RPCs, or claiming every advanced feature is complete. The plugin
catalog route in the current backend is absent (404) and needs integration.