diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml new file mode 100644 index 00000000..8c180fea --- /dev/null +++ b/.gitea/workflows/android.yml @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a1b6ce3..9b8b242c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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().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") } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/data/aiturk/AiturkRelayDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/data/aiturk/AiturkRelayDeviceTest.kt new file mode 100644 index 00000000..e5ccbc96 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/data/aiturk/AiturkRelayDeviceTest.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkCompat.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkCompat.kt index 8a3d92d2..23ea55ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkCompat.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkCompat.kt @@ -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 = + 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 } diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index d621cbb4..35f070df 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index 584e816d..45c420b8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -195,6 +195,7 @@ object ApiClient { .newBuilder() .addInterceptor(authInterceptor) .addInterceptor(ProfileScopeInterceptor) + .addInterceptor(CatalogTimeoutInterceptor) .addInterceptor(logging) .authenticator(TokenRefreshAuthenticator) .build() diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/CatalogTimeoutInterceptor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/CatalogTimeoutInterceptor.kt new file mode 100644 index 00000000..3d9f385a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/CatalogTimeoutInterceptor.kt @@ -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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt index 6b5f4cd6..e3002d42 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt @@ -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") diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt index 6509b24f..8884baff 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt @@ -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 = { refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt index 7cede6ac..5f2c7838 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt @@ -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)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt index 423971ab..5e806f2c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt @@ -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 -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index 27821eac..bf44449b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -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 { val createdResult = - safeApiCall { + safeApiCall(retries = 0) { ApiClient.hermesApi.createCronJob( CreateCronJobRequest( name = editor.name, diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index cc227a8f..0e2957a5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,4 +1,5 @@ + Seçili profil AITURK IDE’de henüz hazır değil. Bilgisayarda IDE’yi açık tutun; kurulum tamamlanınca yeniden deneyin. AITURK Mobil diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 124900c7..5ffa1134 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ + The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes. AITURK Mobil diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkCompatTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkCompatTest.kt index 09f7c444..9dcbba95 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkCompatTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkCompatTest.kt @@ -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, ) { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/CatalogTimeoutInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/CatalogTimeoutInterceptorTest.kt new file mode 100644 index 00000000..8c0d954f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/CatalogTimeoutInterceptorTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt index 8f4103b3..a0088a43 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt @@ -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 diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 3c9fae18..cc61bafc 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -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()) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt index 7d1d3c4c..eee08548 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt @@ -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() + var failures = 0 + val events = mutableListOf() + 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() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt index fd1cc2c4..5d530a6c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -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) + } } diff --git a/docs/aiturk-beta5-acceptance.md b/docs/aiturk-beta5-acceptance.md new file mode 100644 index 00000000..6a944c2d --- /dev/null +++ b/docs/aiturk-beta5-acceptance.md @@ -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.