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
@@ -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
|
||||||
@@ -21,6 +21,7 @@ android {
|
|||||||
applicationId = "online.turkservis.aiturk.mobil"
|
applicationId = "online.turkservis.aiturk.mobil"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
// Version overrides passed from CI via -PversionName / -PversionCode
|
// Version overrides passed from CI via -PversionName / -PversionCode
|
||||||
// Falls back to defaults for local development.
|
// Falls back to defaults for local development.
|
||||||
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
||||||
@@ -326,6 +327,10 @@ val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent
|
|||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
maxParallelForks = 2
|
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
|
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
|
||||||
if (!includeBenchmarks) {
|
if (!includeBenchmarks) {
|
||||||
filter { excludeTestsMatching("*Benchmark") }
|
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_FEATURE_UNAVAILABLE = "This feature is not available in this version of AITURK IDE."
|
||||||
private const val FALLBACK_PHONE_FORBIDDEN =
|
private const val FALLBACK_PHONE_FORBIDDEN =
|
||||||
"For security this cannot be done from the phone. Do it in AITURK IDE on your computer."
|
"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
|
@Volatile
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
@@ -76,6 +78,10 @@ object AiturkCompat {
|
|||||||
fun isLinked(): Boolean =
|
fun isLinked(): Boolean =
|
||||||
linkedOverride ?: runCatching { AiturkLanTrust.isPairedUrl(AuthManager.baseUrl()) }.getOrDefault(false)
|
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 ─────────────────────────────────────────────────────────
|
// ── JSON-RPC ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** True when an awaited call's error must not also be shown as a chat banner. */
|
/** True when an awaited call's error must not also be shown as a chat banner. */
|
||||||
@@ -135,12 +141,14 @@ object AiturkCompat {
|
|||||||
code: Int,
|
code: Int,
|
||||||
errorBody: String?,
|
errorBody: String?,
|
||||||
): 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
|
if (!isLinked()) return null
|
||||||
val body = errorBody?.let { runCatching { Json.parseToJsonElement(it) as? JsonObject }.getOrNull() }
|
val body = errorBody?.let { runCatching { Json.parseToJsonElement(it) as? JsonObject }.getOrNull() }
|
||||||
val error = (body?.get("error") as? JsonPrimitive)?.content
|
val error = (body?.get("error") as? JsonPrimitive)?.content
|
||||||
val detail = (body?.get("detail") as? JsonPrimitive)?.content
|
val detail = (body?.get("detail") as? JsonPrimitive)?.content
|
||||||
return when {
|
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_FORBIDDEN && error == "forbidden" -> phoneForbiddenText()
|
||||||
code == HTTP_NOT_FOUND && (error == "not_found" || detail == "Not Found") -> featureUnavailableText()
|
code == HTTP_NOT_FOUND && (error == "not_found" || detail == "Not Found") -> featureUnavailableText()
|
||||||
else -> null
|
else -> null
|
||||||
@@ -201,4 +209,5 @@ object AiturkCompat {
|
|||||||
|
|
||||||
private const val HTTP_FORBIDDEN = 403
|
private const val HTTP_FORBIDDEN = 403
|
||||||
private const val HTTP_NOT_FOUND = 404
|
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 raw = serverStore.getLatestState().wsAuthParam
|
||||||
val authParam = if (raw.isBlank()) "token" else raw
|
val authParam = if (raw.isBlank()) "token" else raw
|
||||||
return endpointForBuild().webSocketUrl(
|
return endpointForBuild().webSocketUrl(
|
||||||
|
path = "api/ws",
|
||||||
authParameter = authParam,
|
authParameter = authParam,
|
||||||
credential = getToken().orEmpty(),
|
credential = getToken().orEmpty(),
|
||||||
|
extraParams = com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(activeProfileId.value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ object ApiClient {
|
|||||||
.newBuilder()
|
.newBuilder()
|
||||||
.addInterceptor(authInterceptor)
|
.addInterceptor(authInterceptor)
|
||||||
.addInterceptor(ProfileScopeInterceptor)
|
.addInterceptor(ProfileScopeInterceptor)
|
||||||
|
.addInterceptor(CatalogTimeoutInterceptor)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
.authenticator(TokenRefreshAuthenticator)
|
.authenticator(TokenRefreshAuthenticator)
|
||||||
.build()
|
.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,
|
path = KANBAN_EVENTS_PATH,
|
||||||
authParameter = authParam,
|
authParameter = authParam,
|
||||||
credential = credential,
|
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")
|
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
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.model.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
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.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
@@ -19,7 +20,7 @@ class ModelOptionsRepository(
|
|||||||
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||||
},
|
},
|
||||||
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
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 ->
|
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
||||||
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.ws
|
|||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
@@ -171,6 +172,10 @@ internal class ReplayTracker(
|
|||||||
emitEvent(finalEvent)
|
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) {
|
} catch (e: Exception) {
|
||||||
onReplayFailure(sid, e)
|
onReplayFailure(sid, e)
|
||||||
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ object MediaStreamResolver {
|
|||||||
openFileUriStream(uriString, fallbackMime, consumer)
|
openFileUriStream(uriString, fallbackMime, consumer)
|
||||||
}
|
}
|
||||||
|
|
||||||
uriString.startsWith("/") -> {
|
File(uriString).isAbsolute -> {
|
||||||
openLocalFileStream(uriString, fallbackMime, consumer)
|
openLocalFileStream(uriString, fallbackMime, consumer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +168,11 @@ object MediaStreamResolver {
|
|||||||
fallbackMime: String,
|
fallbackMime: String,
|
||||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||||
): T {
|
): T {
|
||||||
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
|
val file =
|
||||||
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
|
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}")
|
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
|
||||||
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
||||||
return file.inputStream().use { inputStream ->
|
return file.inputStream().use { inputStream ->
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ class CronJobsViewModel :
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
withContext(ioDispatcher) {
|
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) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
@@ -466,7 +467,7 @@ class CronJobsViewModel :
|
|||||||
val result =
|
val result =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
||||||
safeApiCall {
|
safeApiCall(retries = 0) {
|
||||||
ApiClient.hermesApi.instantiateBlueprint(
|
ApiClient.hermesApi.instantiateBlueprint(
|
||||||
InstantiateBlueprintRequest(
|
InstantiateBlueprintRequest(
|
||||||
blueprint = editor.selectedBlueprintKey,
|
blueprint = editor.selectedBlueprintKey,
|
||||||
@@ -540,7 +541,7 @@ class CronJobsViewModel :
|
|||||||
*/
|
*/
|
||||||
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
||||||
val createdResult =
|
val createdResult =
|
||||||
safeApiCall {
|
safeApiCall(retries = 0) {
|
||||||
ApiClient.hermesApi.createCronJob(
|
ApiClient.hermesApi.createCronJob(
|
||||||
CreateCronJobRequest(
|
CreateCronJobRequest(
|
||||||
name = editor.name,
|
name = editor.name,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<resources>
|
<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>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<resources>
|
<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>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import org.junit.jupiter.api.Test
|
|||||||
class AiturkCompatTest {
|
class AiturkCompatTest {
|
||||||
private val unavailable = "Bu özellik AITURK IDE'nin bu sürümünde yok."
|
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 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
|
@BeforeEach
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -50,6 +51,7 @@ class AiturkCompatTest {
|
|||||||
when (id) {
|
when (id) {
|
||||||
R.string.aiturk_feature_unavailable -> unavailable
|
R.string.aiturk_feature_unavailable -> unavailable
|
||||||
R.string.aiturk_phone_forbidden -> forbidden
|
R.string.aiturk_phone_forbidden -> forbidden
|
||||||
|
R.string.aiturk_backend_not_ready -> backendNotReady
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,24 @@ class AiturkCompatTest {
|
|||||||
@AfterEach
|
@AfterEach
|
||||||
fun tearDown() = AiturkCompat.resetForTest()
|
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(
|
private class Gateway(
|
||||||
scope: CoroutineScope,
|
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
|
@Test
|
||||||
fun `normalizePath expands tilde`() {
|
fun `normalizePath expands tilde only when home is known`() {
|
||||||
val home = System.getenv("HOME") ?: "/home/test"
|
val home = System.getenv("HOME")
|
||||||
assertEquals("$home/foo.png", GatewayFileClient.normalizePath("~/foo.png"))
|
assertEquals(home?.let { "$it/foo.png" }, GatewayFileClient.normalizePath("~/foo.png"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class RawRpcParamsRatchetTest {
|
|||||||
rootDir
|
rootDir
|
||||||
.walkTopDown()
|
.walkTopDown()
|
||||||
.filter { it.isFile && it.extension == "kt" }
|
.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 ->
|
.forEach { file ->
|
||||||
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
|
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
|
||||||
val code = stripComments(file.readText())
|
val code = stripComments(file.readText())
|
||||||
|
|||||||
@@ -27,6 +27,30 @@ class ReplayTrackerTest {
|
|||||||
|
|
||||||
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
|
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
|
@Test
|
||||||
fun `initial state has empty watermarks and no replay in flight`() {
|
fun `initial state has empty watermarks and no replay in flight`() {
|
||||||
val testDispatcher = StandardTestDispatcher()
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
|||||||
@@ -555,4 +555,30 @@ class CronJobsViewModelTest {
|
|||||||
assertEquals("gateway unreachable", job.last_fire_error?.detail)
|
assertEquals("gateway unreachable", job.last_fire_error?.detail)
|
||||||
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
Reference in new issue
Block a user