Author SHA1 Message Date
yilsem 47a7e35fea Run Android builds on the new server Linux runner
Android acceptance / android (push) Successful in 26m25s
2026-10-11 04:10:51 +03:00
yilsemandClaude Opus 5.5 8b485c0bad ci: check out from the new Gitea (gitea.turkservis.online)
Android acceptance / android (push) Canceled after 0s
TurkServis moved to the new server on 11 Oct 2026; CI now runs on its runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:53:15 +03:00
yilsem 6c17c3f04b test(cron): verify phone editor save/edit/run over real HTTP; clarify server timezone 2026-10-08 04:28:34 +03:00
yilsem dc01c225f7 fix(cron): keep full editor save action readable on phones 2026-10-08 02:57:15 +03:00
yilsem 1f692044d2 fix(cron): preserve chosen clock time across repeat changes 2026-10-08 02:43:32 +03:00
yilsem 8161f3bf09 fix(cron): add repeat dropdown and safe touch time selection 2026-10-08 02:36:10 +03:00
yilsem 1d11afefec feat(cron): visual schedule builder with timepicker and interval presets 2026-10-08 02:17:07 +03:00
yilsem 5006d1c7a8 ci: bound compiler concurrency and size Kotlin heap 2026-10-07 21:15:02 +03:00
yilsem 004d4622c4 ci: transfer the accepted workstation Android SDK license 2026-10-07 20:58:24 +03:00
yilsem 824f0a07e2 ci: accept package licenses at explicit SDK root and verify installation 2026-10-07 20:54:18 +03:00
yilsem 71dd92a4c2 ci: install checksum-pinned Android tools with native Windows extraction 2026-10-07 20:47:21 +03:00
yilsem ebdfeddd9a ci: retain compatible Windows extraction with pinned modern SDK tools 2026-10-07 20:44:03 +03:00
yilsem 017864a0db ci: use current SDK tools and exact Android 37.0 package 2026-10-07 20:37:37 +03:00
yilsem 6472d876b3 ci: isolate Android SDK from spaced Windows user paths 2026-10-07 20:32:39 +03:00
yilsem 9e4e979e7a ci: provision Android SDK and Java on the Windows runner 2026-10-07 20:28:27 +03:00
yilsem cb7e6acb59 fix(mobile): align profile routing, catalog timeout and replay cancellation 2026-10-07 20:23:17 +03:00
yilsemandClaude Opus 5.5 827c74b3c3 feat(aiturk): hide what the paired IDE cannot serve, plain error sentences, reconnect on return
When paired with AITURK IDE:
- an "unknown method" (-32601) answer and the proxy's 403/route-404 are shown
  as plain sentences, never as raw JsonRpcError text; a method the IDE does
  not have is not sent again on the same socket
- Account connectors, Vault, Keys and Files are hidden (the IDE has no
  connectors.* / vault.* and its phone proxy refuses /api/env and /api/files)
- an idle-closed chat socket is reopened when the app returns to the
  foreground; idle tunnel connections are dropped on a network change
- an unsaved chat no longer polls /api/sessions/<id>
- the pairing screen shows the app version

A plain Hermes connection is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 17:21:32 +03:00
yilsemandClaude Opus 5.5 c2d828b90a feat(aiturk): say when AITURK IDE is closed in the chat banner
When the tunnel reports that no computer is attached (close 4503) the
reconnect banner now says that AITURK IDE is not open or Phone
connection is off, instead of an endless 'reconnecting'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 04:18:53 +03:00
yilsemandClaude Opus 5.5 cbe716300c feat(aiturk): reach the IDE through an end-to-end encrypted tunnel
A phone on the same Wi-Fi could not reach the computer at all (client
isolation on the router), and every failure was shown as one 'same
Wi-Fi' message. Contract v3: the phone only dials turkservis.online.

Transport: a relay pairing lives on the virtual host
<relayId>.relay.aiturk.invalid. For that host only, the shared OkHttp
client resolves a placeholder address, opens a WebSocket byte stream to
the relay instead of a TCP connection, and runs TLS on top of it pinned
to the IDE's certificate fingerprint. The relay sees ciphertext only.
DNS, sockets, proxy choice and TLS are unchanged for every other host.

Pairing: tunnel first (hello within 10 s), LAN candidates second, one
claim only. The local-network permission is requested only when a LAN
address may be tried; denying it no longer ends the pairing.

Errors: host offline, unknown relay, busy, no internet, silent computer,
certificate mismatch and the LAN failures each get their own text plus
a short diagnostic code built from a fixed vocabulary (never a token,
pairing code or address). After pairing the screen shows the first 8
characters of the fingerprint as a security code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 04:18:52 +03:00
yilsemandClaude Opus 5.5 9afac6f508 feat(aiturk): accept a relay id in pairing payloads
Contract v3 (tunnel): the QR / deep link may carry r=<lowercase UUIDv4>
and no LAN address at all (the IDE's default). u is now 0..4, but a
payload with neither u nor r is rejected, and a present-but-invalid r
rejects the whole payload instead of falling back to the LAN addresses.

AiturkRelay maps a relay id to the virtual host
<relayId>.relay.aiturk.invalid that addresses a tunnel pairing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 04:08:07 +03:00
58 changed files with 4880 additions and 211 deletions

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
name: Android acceptance
on:
push:
branches: [main]
workflow_dispatch:
jobs:
android:
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
with:
github-server-url: https://gitea.turkservis.online
- name: Set up Java 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Android SDK
shell: bash
run: bash scripts/setup-android-ci.sh
- name: Test and build exact commit
shell: bash
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
uname -a
free -m
# One compiler process inside the server's bounded, serial CI runner.
build_args=(--no-daemon --console=plain --max-workers=1
'-Dorg.gradle.jvmargs=-Xmx2560m -Dfile.encoding=UTF-8'
'-Pkotlin.compiler.execution.strategy=in-process')
bash gradlew testDebugUnitTest assembleDebug "${build_args[@]}"
bash gradlew compileReleaseKotlin "${build_args[@]}"
- name: Keep exact-commit APK and test reports
uses: actions/upload-artifact@v3
with:
name: aiturk-android-${{ github.sha }}
if-no-files-found: error
path: |
app/build/outputs/apk/debug/app-debug.apk
app/build/reports/tests/testDebugUnitTest
+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)
}
}
}
@@ -0,0 +1,115 @@
package com.m57.hermescontrol.ui.cron
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import java.net.URL
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
/** Opt-in test against a fresh, isolated real cron backend, never a user server. */
class ScheduleHttpAcceptanceTest {
@get:Rule val compose = createComposeRule()
@Test
fun editorCreatesEditsAndScheduledJobExecutesViaHttp() {
val args = InstrumentationRegistry.getArguments()
assumeTrue(args.getString("isolatedCronAcceptance") == "true")
val context = InstrumentationRegistry.getInstrumentation().targetContext
check(BuildConfig.DEBUG && context.packageName.endsWith(".dev"))
val url = "http://127.0.0.1:18765"
AuthManager.init(context)
kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
val oldUrl = AuthManager.getBaseUrl()
val oldToken = AuthManager.getToken()
val store = ViewModelStore()
val owner = object : ViewModelStoreOwner { override val viewModelStore = store }
try {
AuthManager.setBaseUrl(url)
AuthManager.setToken(null)
ApiClient.rebuild()
val vm = ViewModelProvider(owner)[CronJobsViewModel::class.java]
compose.setContent {
val state by vm.uiState.collectAsState()
MaterialTheme {
if (state.editorState.isOpen) {
CronJobEditorDialog(
state = state.editorState,
onFieldChange = vm::updateEditorField,
onToggleNoAgent = vm::toggleNoAgent,
onToggleRunContinuity = vm::toggleRunContinuity,
onSetMonitorMode = vm::setMonitorMode,
onSelectBlueprint = vm::selectBlueprint,
onBlueprintFieldChange = vm::updateBlueprintValue,
onSave = vm::saveEditor,
onDismiss = vm::closeEditor,
onClearToast = vm::clearEditorToast,
)
}
}
}
// Harmless execution fixture. Schedule is changed using the real clock below.
compose.runOnIdle {
vm.openNewJobDialog()
vm.updateEditorField("name", "Android HTTP clock acceptance")
vm.updateEditorField("script", "probe.py")
vm.updateEditorField("schedule", "59 23 * * *")
vm.toggleNoAgent()
}
compose.onNodeWithText("23:59").performScrollTo().performClick()
compose.onNodeWithText("59").performClick()
compose.onNode(hasContentDescription("55", substring = true)).performClick()
compose.onNodeWithText(context.getString(R.string.action_ok)).performClick()
compose.onNodeWithText(context.getString(R.string.cron_edit_action_save)).performClick()
compose.waitUntil(30_000) { !vm.uiState.value.editorState.isOpen }
compose.waitUntil(30_000) { vm.uiState.value.jobs.size == 1 }
val id = vm.uiState.value.jobs.single().id
compose.runOnIdle { vm.openEditJobDialog(id) }
compose.waitUntil(30_000) { !vm.uiState.value.editorState.isLoading }
compose.runOnIdle { assertEquals("55 23 * * *", vm.uiState.value.editorState.schedule) }
compose.onNodeWithText("23:55").performScrollTo()
compose.onNodeWithText(context.getString(R.string.cron_repeat_label)).performClick()
compose.onAllNodesWithText(context.getString(R.string.cron_repeat_minutely))[0].performClick()
compose.onNodeWithText(context.getString(R.string.cron_edit_action_save)).performClick()
compose.waitUntil(30_000) { !vm.uiState.value.editorState.isOpen }
val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
var evidence = JSONObject()
while (android.os.SystemClock.elapsedRealtime() < deadline) {
evidence = JSONObject(URL("$url/acceptance/evidence").readText())
if (evidence.optString("result") == "PASS") break
android.os.SystemClock.sleep(500)
}
assertEquals(evidence.toString(), "PASS", evidence.optString("result"))
assertEquals(id, evidence.getString("job_id"))
assertEquals(1, evidence.getInt("runs"))
val requests = evidence.getJSONArray("requests")
assertEquals(2, requests.length())
assertEquals("55 23 * * *", requests.getJSONObject(0).getJSONObject("body").getString("schedule"))
assertEquals("* * * * *", requests.getJSONObject(1).getJSONObject("body").getJSONObject("updates").getString("schedule"))
assertTrue(requests.getJSONObject(1).getString("path").endsWith(id))
} finally {
compose.runOnIdle { store.clear() }
AuthManager.setBaseUrl(oldUrl)
AuthManager.setToken(oldToken)
ApiClient.rebuild()
}
}
}
@@ -0,0 +1,108 @@
package com.m57.hermescontrol.ui.cron
import android.graphics.Bitmap
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class SchedulePickerTest {
@get:Rule val compose = createComposeRule()
@Test
fun fullEditorHasReachableSaveAction() {
var saved = false
val context = InstrumentationRegistry.getInstrumentation().targetContext
compose.setContent {
MaterialTheme {
CronJobEditorDialog(
state = CronJobEditorState(isOpen = true, isNew = true, schedule = "59 23 * * *"),
onFieldChange = { _, _ -> },
onToggleNoAgent = {},
onToggleRunContinuity = {},
onSetMonitorMode = {},
onSelectBlueprint = {},
onBlueprintFieldChange = { _, _ -> },
onSave = { saved = true },
onDismiss = {},
onClearToast = {},
)
}
}
compose.waitForIdle()
android.os.SystemClock.sleep(500)
val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
File(context.getExternalFilesDir(null), "scheduler-editor-360.png").outputStream().use {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
}
compose.onNodeWithText(context.getString(R.string.cron_edit_action_save))
.assertIsDisplayed().performClick()
compose.runOnIdle { assertTrue(saved) }
}
@Test
fun repeatDropdownAndClockPreserveSchedule() {
var schedule by mutableStateOf("59 23 * * *")
val context = InstrumentationRegistry.getInstrumentation().targetContext
compose.setContent {
MaterialTheme {
Surface {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
SchedulePickerSection(schedule) { schedule = it }
}
}
}
}
fun text(id: Int) = context.getString(id)
fun screenshot(name: String) {
compose.waitForIdle()
android.os.SystemClock.sleep(500)
val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
File(context.getExternalFilesDir(null), "$name.png").outputStream().use {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
}
}
screenshot("schedule-daily-360")
compose.onNodeWithText("23:59").performClick()
screenshot("schedule-clock-360")
compose.onNodeWithText("59").performClick()
compose.onNode(hasContentDescription("55", substring = true)).performClick()
compose.onNodeWithText(text(R.string.action_ok)).performClick()
compose.runOnIdle { assertEquals("55 23 * * *", schedule) }
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
screenshot("schedule-repeat-360")
compose.onAllNodesWithText(text(R.string.cron_repeat_yearly))[0].performClick()
compose.runOnIdle { assertEquals("55 23 1 1 *", schedule) }
screenshot("schedule-yearly-360")
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
compose.onAllNodesWithText(text(R.string.cron_repeat_hourly))[0].performClick()
compose.runOnIdle { assertEquals("55 * * * *", schedule) }
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
compose.onAllNodesWithText(text(R.string.cron_schedule_mode_daily))[0].performClick()
compose.runOnIdle { assertEquals("55 23 * * *", schedule) }
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
compose.onAllNodesWithText(text(R.string.cron_repeat_minutely))[0].performClick()
compose.runOnIdle { assertEquals("* * * * *", schedule) }
}
}
@@ -35,6 +35,8 @@ class HermesControlApp :
}
AuthManager.init(this)
NetworkMonitor.init(this)
com.m57.hermescontrol.data.aiturk.AiturkCompat
.init(this)
SessionListCacheStore.init(this)
// Reply-notification turn boundaries survive process death (armed before
// each mobile prompt, consumed by the matching completion).
@@ -52,7 +52,9 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLinkEffect
import com.m57.hermescontrol.ui.aiturkpair.AiturkUnavailableScreen
import com.m57.hermescontrol.ui.common.DisableDrawerGestures
import com.m57.hermescontrol.ui.common.DrawerGestureController
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
@@ -115,7 +117,11 @@ private fun appEntryProvider(
ScreenRegistry.ALL_SCREENS.forEach { screen ->
addEntryProvider(clazz = screen.key::class) {
screen.content(sessionId, openDrawer)
if (AiturkFeatureGate.shows(screen.key)) {
screen.content(sessionId, openDrawer)
} else {
AiturkUnavailableScreen(titleRes = screen.labelRes, onOpenDrawer = openDrawer)
}
}
}
@@ -325,6 +331,7 @@ fun MainNavigation(sessionId: String? = null) {
)
ScreenRegistry.ALL_SCREENS
.filter { it.drawerSection == section }
.filter { AiturkFeatureGate.shows(it.key) }
.forEach { entry ->
NavigationDrawerItem(
icon = { Icon(entry.icon, contentDescription = null) },
@@ -0,0 +1,213 @@
package com.m57.hermescontrol.data.aiturk
import android.content.Context
import androidx.annotation.StringRes
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* What differs when the app talks to a paired AITURK IDE instead of a plain
* Hermes dashboard, kept in one place so the upstream files only carry
* one-line hooks.
*
* AITURK IDE's agent is an older/forked Hermes gateway: some JSON-RPC methods
* this app knows (`connectors.*`, `vault.*`, `subagent.list` …) are not
* registered there and answer `-32601 unknown method`, and the IDE's phone
* proxy refuses secret and settings routes with `403 {"error":"forbidden"}`.
* Neither is an error the user can act on, so:
*
* - an awaited call that hits `-32601` fails quietly (no chat banner) and the
* method is remembered until the next socket, so it is not sent again;
* - every text shown for such an answer is a plain sentence, never the raw
* `JsonRpcError(code=-32601, …)` or `unknown method` wording;
* - idle pooled tunnel connections are dropped when the phone changes network
* and the chat socket is reopened when the app returns to the foreground.
*
* Nothing here runs for a connection that is not a paired AITURK IDE.
*/
object AiturkCompat {
const val METHOD_NOT_FOUND = -32601
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
/** Tests set this instead of pairing a real address. */
@Volatile
internal var linkedOverride: Boolean? = null
/** Tests replace the string lookup (no Android resources on the JVM). */
@Volatile
internal var stringLookup: ((Int) -> String?)? = null
private val missingMethods: MutableSet<String> = ConcurrentHashMap.newKeySet()
private val started = AtomicBoolean(false)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Called once from the Application: string lookup and the network-change hook. */
fun init(context: Context) {
appContext = context.applicationContext
if (started.compareAndSet(false, true)) {
scope.launch {
NetworkMonitor.networkChanges.collect { available -> if (available) onNetworkChanged() }
}
}
}
/** True while the active connection is a paired AITURK IDE (LAN or tunnel). */
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. */
fun quietRpcError(code: Int): Boolean = code == METHOD_NOT_FOUND && isLinked()
/** Remembers that the IDE on the other end does not have [method]. */
fun noteRpcError(
method: String?,
code: Int,
) {
if (method != null && quietRpcError(code)) missingMethods.add(method)
}
/** True when [method] already answered "unknown method" on this socket. */
fun isKnownMissing(method: String): Boolean = method in missingMethods && isLinked()
/** A new socket may be a newer IDE: ask again. */
fun onSocketOpened() {
missingMethods.clear()
}
/** The exception a locally refused call fails with (same code the IDE would send). */
fun missingMethodError(): HermesWsClient.HermesRpcException =
HermesWsClient.HermesRpcException(featureUnavailableText(), code = METHOD_NOT_FOUND)
/** Message of the exception an awaited call fails with. */
fun rpcExceptionMessage(
code: Int,
message: String,
): String = if (quietRpcError(code)) featureUnavailableText() else message
/**
* Chat banner text for an RPC error event. [legacy] is the upstream text,
* used unchanged for every connection that is not a paired AITURK IDE.
*/
fun rpcBannerText(
error: Any?,
legacy: String,
): String {
if (!isLinked()) return legacy
val code = (error as? JsonRpcError)?.code ?: ((error as? Map<*, *>)?.get("code") as? Number)?.toInt()
if (code == METHOD_NOT_FOUND) return featureUnavailableText()
val message = (error as? JsonRpcError)?.message ?: (error as? Map<*, *>)?.get("message") as? String
return message?.takeIf { it.isNotBlank() } ?: legacy
}
// ── REST ─────────────────────────────────────────────────────────────
/**
* A plain sentence for the two answers that mean "not from the phone" /
* "not in this IDE": the proxy's `403 {"error":"forbidden"}` and a route
* the IDE does not have (`404 {"detail":"Not Found"}`, the proxy's
* `404 {"error":"not_found"}`). Null for everything else, including a 404
* that names a real missing record ("Session not found").
*/
fun httpErrorMessage(
code: Int,
errorBody: String?,
): String? {
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
}
}
/**
* A brand-new chat has no row in the IDE's session store until its first
* message, so `GET /api/sessions/<id>` answers 404 on every poll.
*/
fun skipSessionDetail(transcriptEmpty: Boolean): Boolean = transcriptEmpty && isLinked()
// ── Network and lifecycle ────────────────────────────────────────────
/**
* The default network changed (Wi-Fi ⇄ mobile data). Idle pooled
* connections still ride the old network's tunnel stream; drop them so the
* next REST call opens a fresh stream instead of waiting for a read timeout.
*/
internal fun onNetworkChanged(evictIdle: () -> Unit = { OkHttpProvider.base.connectionPool.evictAll() }) {
if (isLinked()) runCatching(evictIdle)
}
/**
* The app came back to the foreground. An idle socket is closed on purpose
* in the background; reopen it so the user does not have to tap "reconnect".
* A socket that is open, opening, or signed out is left alone.
*/
fun onAppForeground(
status: ConnectionStatus = HermesWsClient.connectionStatus.value,
autoReconnect: () -> Boolean = { runCatching { AuthManager.isAutoReconnect() }.getOrDefault(false) },
connect: () -> Unit = { HermesWsClient.connect() },
) {
if (status == ConnectionStatus.DISCONNECTED && isLinked() && autoReconnect()) connect()
}
// ── Text ─────────────────────────────────────────────────────────────
/** "v1.2.3 (45)": what the pair screen and Settings → About show. */
fun versionLabel(
name: String = BuildConfig.VERSION_NAME,
code: Int = BuildConfig.VERSION_CODE,
): String = "v$name ($code)"
fun featureUnavailableText(): String = string(R.string.aiturk_feature_unavailable) ?: FALLBACK_FEATURE_UNAVAILABLE
fun phoneForbiddenText(): String = string(R.string.aiturk_phone_forbidden) ?: FALLBACK_PHONE_FORBIDDEN
private fun string(
@StringRes id: Int,
): String? = stringLookup?.invoke(id) ?: runCatching { appContext?.getString(id) }.getOrNull()
internal fun resetForTest() {
linkedOverride = null
stringLookup = null
missingMethods.clear()
}
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404
private const val HTTP_UNAVAILABLE = 503
}
@@ -15,11 +15,14 @@ object AiturkConnectionRegistrar {
const val FALLBACK_PC_NAME = "AITURK IDE"
suspend fun register(device: AiturkPairedDevice) {
val baseUrl = device.endpoint.baseUrl
// The address the claim was sent to: the LAN `https://ip:port/` or the
// virtual tunnel host `https://<relayId>.relay.aiturk.invalid/`.
val baseUrl = device.baseUrl
val name = device.pcName.ifBlank { FALLBACK_PC_NAME }
// Trust first: every later request to this host:port is checked against the pin.
AiturkLanTrust.pin(device.endpoint.hostPort, device.fingerprint)
// Trust first: every later request to this host:port — over the LAN or
// through the tunnel — is checked against the pin, end to end.
AiturkLanTrust.pin(device.hostPort, device.fingerprint)
withContext(Dispatchers.IO) { HermesWsClient.disconnect() }
@@ -44,6 +44,7 @@ object AiturkLanTrust {
}
private const val PREF_KEY = "aiturk_lan_pins"
private val IPV4_HOST_PORT = Regex("""\d{1,3}(\.\d{1,3}){3}:\d{1,5}""")
private val pins = ConcurrentHashMap<String, String>()
@@ -71,6 +72,9 @@ object AiturkLanTrust {
builder
.sslSocketFactory(sslSocketFactory, trustManager)
.hostnameVerifier(hostnameVerifier)
// A relay pairing lives on a virtual host: DNS, socket and proxy
// choice are special-cased for that host only (see AiturkTunnel).
.let(AiturkTunnel::applyTo)
}.getOrDefault(builder)
/**
@@ -80,6 +84,7 @@ object AiturkLanTrust {
fun pinnedClient(
base: OkHttpClient,
fingerprint: String,
tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector },
): OkHttpClient {
val lookup: (String) -> String? = { fingerprint }
val manager = PinAwareTrustManager(RejectAllTrustManager, lookup) { listOf(fingerprint) }
@@ -87,6 +92,7 @@ object AiturkLanTrust {
.newBuilder()
.sslSocketFactory(socketFactoryFor(manager), manager)
.hostnameVerifier(PinAwareHostnameVerifier({ _, _ -> false }, lookup))
.let { AiturkTunnel.applyTo(it, tunnel) }
.build()
}
@@ -95,6 +101,11 @@ object AiturkLanTrust {
fingerprint: String,
) {
require(AiturkPairing.isValidFingerprint(fingerprint)) { "Invalid certificate fingerprint" }
// Pins are for the IDE's own TLS proxy only: its LAN `ip:port`, or the
// virtual tunnel host. A real DNS name keeps the platform's checks.
require(IPV4_HOST_PORT.matches(hostPort) || isRelayHostPort(hostPort)) {
"Only an AITURK IDE address can be pinned"
}
ensureLoaded()
// Saving on top of an unread store would drop the pins already in it.
check(loaded) { "Secure storage is not ready" }
@@ -152,11 +163,18 @@ object AiturkLanTrust {
return MessageDigest.isEqual(expected, actual)
}
private fun isRelayHostPort(hostPort: String): Boolean =
hostPort.endsWith(":${AiturkRelay.PORT}") && AiturkRelay.isVirtualHost(hostPort.substringBeforeLast(':'))
// Wrapped so that TLS over a tunnel stream is layered on a socket without a
// file descriptor; for every other socket the wrapper is a pass-through.
private fun socketFactoryFor(manager: X509TrustManager): SSLSocketFactory =
SSLContext
.getInstance("TLS")
.apply { init(null, arrayOf(manager), null) }
.socketFactory
AiturkTunnelSslSocketFactory(
SSLContext
.getInstance("TLS")
.apply { init(null, arrayOf(manager), null) }
.socketFactory,
)
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
@@ -2,13 +2,15 @@ package com.m57.hermescontrol.data.aiturk
import com.m57.hermescontrol.data.remote.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
@@ -22,7 +24,18 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.Buffer
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.PortUnreachableException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.cert.CertificateException
import java.util.Locale
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
/** Error names follow the pairing contract (`wrong_code`, `pairing_closed`, …). */
enum class AiturkPairError {
@@ -35,9 +48,33 @@ enum class AiturkPairError {
/** Gateway unreachable or answered something unexpected. */
GATEWAY_UNAVAILABLE,
/** No candidate address answered `hello` with the pinned certificate in time. */
/** No LAN candidate answered `hello` with the pinned certificate in time. */
PC_UNREACHABLE,
/** Tunnel closed with 4503: AITURK IDE is not running or Phone connection is off. */
HOST_OFFLINE,
/** Tunnel closed with 4504, or the computer stayed silent inside the tunnel. */
HOST_TIMEOUT,
/** Tunnel closed with 4429: too many streams for this relay. */
RELAY_BUSY,
/** Tunnel closed with 4404: the relay id no longer exists. */
RELAY_UNKNOWN,
/** turkservis.online could not be reached at all (no internet, DNS, timeout). */
NO_INTERNET,
/** Inside the tunnel the certificate did not match the pairing fingerprint. */
TLS_PIN,
/** The tunnel ended in a way the contract does not name. */
RELAY_ERROR,
/** Only LAN candidates were left and the local-network permission is denied. */
LAN_PERMISSION,
/** `claim` 400 `invalid_request` (or 413). */
INVALID_REQUEST,
@@ -54,34 +91,130 @@ enum class AiturkPairError {
UNEXPECTED_RESPONSE,
}
/**
* [baseUrl] is the address the `claim` was sent to (trailing slash): either the
* LAN `https://<ip:port>/` ([endpoint] set) or the virtual tunnel host
* `https://<relayId>.relay.aiturk.invalid/` ([endpoint] null). In both cases the
* certificate is pinned to [fingerprint] under [hostPort].
*/
data class AiturkPairedDevice(
val endpoint: AiturkEndpoint,
val baseUrl: String,
val hostPort: String,
val endpoint: AiturkEndpoint?,
val fingerprint: String,
val token: String,
val deviceId: String,
val pcName: String,
val version: String,
)
) {
val viaRelay: Boolean get() = endpoint == null
}
sealed interface AiturkPairResult<out T> {
data class Success<T>(
val value: T,
) : AiturkPairResult<T>
/**
* [code] is a short diagnostic shown in small print (`relay:host_offline`,
* `tls:pin`, `lan:timeout`, `relay:network,lan:refused` …). It is assembled only from
* the fixed vocabulary in [AiturkDiag] plus an HTTP status number: never a
* token, pairing code, address or exception message.
*/
data class Failure(
val error: AiturkPairError,
val code: String = "",
) : AiturkPairResult<Nothing>
}
/** Fixed vocabulary of the diagnostic codes. */
object AiturkDiag {
const val TIMEOUT = "timeout"
const val DNS = "dns"
const val TLS = "tls"
const val REFUSED = "refused"
const val UNREACHABLE = "unreachable"
const val PERMISSION = "permission"
const val IO = "io"
const val BAD_RESPONSE = "bad_response"
const val NO_CANDIDATES = "no_candidates"
const val PAIRING_CLOSED = "pairing_closed"
const val PIN = "pin"
const val NETWORK = "network"
private const val MAX_CAUSE_DEPTH = 6
private val safe = Regex("[a-z]{1,8}:[a-z0-9_]{1,24}(,[a-z]{1,8}:[a-z0-9_]{1,24})*")
/** Defence in depth for the UI: anything outside the vocabulary shape is not shown. */
fun sanitize(code: String?): String = code?.takeIf { it.length <= MAX_CODE_LENGTH && safe.matches(it) }.orEmpty()
/**
* Which kind of network failure [error] is. Looks at the exception TYPE and,
* for connect failures, at the errno name the platform puts in the message
* (`ECONNREFUSED`, `EHOSTUNREACH`, …). The message itself is never returned.
*/
fun networkKind(error: Throwable): String {
val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList()
val text = chain.joinToString(" ") { it.message.orEmpty() }.uppercase(Locale.ROOT)
fun mentions(vararg needles: String) = needles.any { it in text }
return when {
chain.any { it is SSLException || it is CertificateException } -> TLS
chain.any { it is UnknownHostException } -> DNS
mentions("EPERM", "EACCES", "OPERATION NOT PERMITTED", "PERMISSION DENIED") -> PERMISSION
chain.any { it is NoRouteToHostException || it is PortUnreachableException } -> UNREACHABLE
mentions("EHOSTUNREACH", "ENETUNREACH", "NO ROUTE TO HOST", "NETWORK IS UNREACHABLE") -> UNREACHABLE
chain.any { it is SocketTimeoutException } || mentions("ETIMEDOUT") -> TIMEOUT
chain.any { it is ConnectException } -> REFUSED
// OkHttp's callTimeout surfaces as InterruptedIOException("timeout").
chain.any { it is InterruptedIOException } -> TIMEOUT
else -> IO
}
}
/**
* The same question for a request that went through the tunnel. The relay's
* own verdict (close code) wins; a certificate that does not match the
* pairing fingerprint is [PIN]; silence inside an open tunnel is [TIMEOUT];
* everything else is the network between the phone and turkservis.online.
*/
fun tunnelKind(error: Throwable): String {
AiturkTunnelException.find(error)?.let { return it.kind }
val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList()
return when {
chain.any { it is CertificateException || it is javax.net.ssl.SSLPeerUnverifiedException } -> PIN
chain.any { it is InterruptedIOException } -> TIMEOUT
chain.any { it is SSLException } -> TLS
else -> NETWORK
}
}
/** `relay:<kind>`, except the pin failure which the contract names `tls:pin`. */
fun relayCode(kind: String): String = if (kind == PIN) "tls:$PIN" else "relay:$kind"
private const val MAX_CODE_LENGTH = 96
}
/**
* Phone side of the AITURK IDE pairing handshake.
*
* Deliberately does not log: codes, tokens and bodies must never reach logcat.
* Contract: v1 (`aiturk-mobil-eslestirme-spec`) plus v3 (`aiturk-mobil-tunel-spec`).
*/
class AiturkPairingClient(
private val gatewayBaseUrl: HttpUrl = DEFAULT_GATEWAY.toHttpUrl(),
private val baseClient: OkHttpClient = defaultBaseClient,
private val helloTimeoutMs: Long = HELLO_TIMEOUT_MS,
private val relayHelloTimeoutMs: Long = RELAY_HELLO_TIMEOUT_MS,
private val tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector },
) {
/** Code path: `resolve` the rendezvous record for a normalized code. */
suspend fun resolve(normalizedCode: String): AiturkPairResult<AiturkPairingOffer> =
@@ -93,7 +226,7 @@ class AiturkPairingClient(
val request =
Request
.Builder()
.url(gatewayBaseUrl.newBuilder().addPathSegments(RESOLVE_PATH).build())
.url(under(gatewayBaseUrl, RESOLVE_PATH))
.post(body)
.build()
val client =
@@ -105,47 +238,144 @@ class AiturkPairingClient(
client.newCall(request).await().use { response ->
when (response.code) {
200 -> parseOffer(readJson(response))
404 -> AiturkPairResult.Failure(AiturkPairError.CODE_NOT_FOUND)
429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED)
else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
404 -> AiturkPairResult.Failure(AiturkPairError.CODE_NOT_FOUND, "resolve:not_found")
429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED, "resolve:rate_limited")
else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:http_${response.code}")
}
}
} catch (_: java.io.IOException) {
AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
} catch (_: IOException) {
AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:network")
}
}
/**
* `hello` to every candidate in parallel (pinned to the offer's fingerprint),
* then exactly ONE `claim` to the first that answered.
* Tunnel first, LAN second, and exactly ONE `claim`:
* 1. [AiturkPairingOffer.relay] present → `hello` to the virtual tunnel host
* (TLS inside the tunnel, pinned to the offer's fingerprint). Answered →
* the single `claim` goes through the tunnel and the LAN candidates are
* never contacted.
* 2. Relay absent or failed → `hello` to every LAN candidate in parallel
* (same pin), single `claim` to the first answer.
*
* [lanAllowed] false (Android 17+ local-network permission denied) skips step
* 2 and reports `lan:permission`. When both steps fail the relay's failure is
* the user-facing error and both codes are kept (`relay:…,lan:…`).
*/
suspend fun pair(
offer: AiturkPairingOffer,
normalizedCode: String,
deviceName: String,
lanAllowed: Boolean = true,
): AiturkPairResult<AiturkPairedDevice> =
withContext(Dispatchers.IO) {
val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint)
val reachable =
firstReachable(pinned, offer.endpoints)
?: return@withContext AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE)
if (!reachable.pairingOpen) {
// The computer says pairing is not open: a claim could only be
// answered with 410 pairing_closed, so do not spend an attempt.
return@withContext AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED)
var relayFailure: AiturkPairResult.Failure? = null
// The only trust on this client is the offer's fingerprint: on the
// LAN and inside the tunnel alike.
val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint, tunnel)
// Validated again here: an offer built by hand must not widen the target.
val relayId = offer.relay?.let(AiturkRelay::parse)
if (relayId != null) {
val relayBase = AiturkRelay.baseUrl(relayId).toHttpUrl()
when (val hello = relayHello(pinned, relayBase)) {
is Hello.Ok -> {
if (!hello.pairingOpen) {
return@withContext AiturkPairResult.Failure(
AiturkPairError.PAIRING_CLOSED,
"relay:${AiturkDiag.PAIRING_CLOSED}",
)
}
return@withContext claim(
pinned,
relayBase,
AiturkRelay.hostPort(relayId),
null,
offer.fingerprint,
normalizedCode,
deviceName,
)
}
is Hello.Failed -> {
relayFailure = AiturkPairResult.Failure(relayError(hello.kind), AiturkDiag.relayCode(hello.kind))
}
}
}
claim(pinned, reachable.endpoint, offer.fingerprint, normalizedCode, deviceName)
val lan = pairOverLan(pinned, offer, normalizedCode, deviceName, lanAllowed)
if (lan is AiturkPairResult.Failure && relayFailure != null && lan.code.startsWith("lan:")) {
// Nothing was claimed on the LAN either: the relay is the designed
// path, so its failure is what the user needs to hear about.
return@withContext AiturkPairResult.Failure(relayFailure.error, "${relayFailure.code},${lan.code}")
}
lan
}
private data class Reachable(
val endpoint: AiturkEndpoint,
val pairingOpen: Boolean,
)
private suspend fun pairOverLan(
pinned: OkHttpClient,
offer: AiturkPairingOffer,
normalizedCode: String,
deviceName: String,
lanAllowed: Boolean,
): AiturkPairResult<AiturkPairedDevice> {
if (offer.endpoints.isEmpty()) {
return AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE, "lan:${AiturkDiag.NO_CANDIDATES}")
}
if (!lanAllowed) {
return AiturkPairResult.Failure(AiturkPairError.LAN_PERMISSION, "lan:${AiturkDiag.PERMISSION}")
}
val reachable =
when (val hello = firstReachable(pinned, offer.endpoints)) {
is Hello.Ok -> hello
is Hello.Failed -> return AiturkPairResult.Failure(lanError(hello.kind), "lan:${hello.kind}")
}
if (!reachable.pairingOpen) {
// The computer says pairing is not open: a claim could only be
// answered with 410 pairing_closed, so do not spend an attempt.
return AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED, "lan:${AiturkDiag.PAIRING_CLOSED}")
}
val endpoint = checkNotNull(reachable.endpoint)
return claim(
pinned,
endpoint.baseUrl.toHttpUrl(),
endpoint.hostPort,
endpoint,
offer.fingerprint,
normalizedCode,
deviceName,
)
}
private sealed interface Hello {
data class Ok(
val endpoint: AiturkEndpoint?,
val pairingOpen: Boolean,
) : Hello
/** [kind] is one of the [AiturkDiag] words or `http_<status>`. */
data class Failed(
val kind: String,
) : Hello
}
private suspend fun relayHello(
pinned: OkHttpClient,
relayBase: HttpUrl,
): Hello {
val client =
pinned
.newBuilder()
.connectTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
.readTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
.callTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
.build()
return withTimeoutOrNull(relayHelloTimeoutMs + HELLO_MARGIN_MS) { hello(client, relayBase, null) }
?: Hello.Failed(AiturkDiag.TIMEOUT)
}
private suspend fun firstReachable(
pinned: OkHttpClient,
endpoints: List<AiturkEndpoint>,
): Reachable? {
): Hello {
val helloClient =
pinned
.newBuilder()
@@ -153,78 +383,128 @@ class AiturkPairingClient(
.readTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
.callTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
.build()
val failures = CopyOnWriteArrayList<String>()
// Small margin over the per-call timeout so a slow candidate cannot hold the result.
return withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) {
channelFlow {
endpoints.forEach { endpoint ->
launch { hello(helloClient, endpoint)?.let { send(it) } }
val winner =
withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) {
coroutineScope {
val answers = Channel<Hello>(endpoints.size)
endpoints.forEach { endpoint ->
launch { answers.send(hello(helloClient, endpoint.baseUrl.toHttpUrl(), endpoint)) }
}
var ok: Hello.Ok? = null
var received = 0
while (ok == null && received < endpoints.size) {
when (val answer = answers.receive()) {
is Hello.Ok -> ok = answer
is Hello.Failed -> failures += answer.kind
}
received++
}
coroutineContext.cancelChildren()
ok
}
}.firstOrNull()
}
}
if (winner != null) return winner
// Candidates still silent when the window closed count as timeouts.
if (failures.size < endpoints.size) failures += AiturkDiag.TIMEOUT
return Hello.Failed(mostTelling(failures))
}
private suspend fun hello(
client: OkHttpClient,
endpoint: AiturkEndpoint,
): Reachable? =
base: HttpUrl,
endpoint: AiturkEndpoint?,
): Hello =
try {
val request = Request.Builder().url(lanUrl(endpoint, HELLO_PATH)).get().build()
val request = Request.Builder().url(under(base, HELLO_PATH)).get().build()
client.newCall(request).await().use { response ->
if (response.code != 200) return null
val json = readJson(response) ?: return null
if (json.string("app") != IDE_APP_ID) return null
Reachable(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false)
val json = readJson(response)
when {
response.code != 200 -> {
Hello.Failed("http_${response.code}")
}
json?.string("app") != IDE_APP_ID -> {
Hello.Failed(AiturkDiag.BAD_RESPONSE)
}
else -> {
Hello.Ok(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false)
}
}
}
} catch (_: java.io.IOException) {
null
} catch (e: IOException) {
Hello.Failed(if (endpoint == null) AiturkDiag.tunnelKind(e) else AiturkDiag.networkKind(e))
}
private suspend fun claim(
pinned: OkHttpClient,
endpoint: AiturkEndpoint,
client: OkHttpClient,
base: HttpUrl,
hostPort: String,
endpoint: AiturkEndpoint?,
fingerprint: String,
normalizedCode: String,
deviceName: String,
): AiturkPairResult<AiturkPairedDevice> {
val viaRelay = endpoint == null
val body =
buildJsonObject {
put("code", normalizedCode)
put("device_name", sanitizeDeviceName(deviceName))
}.toString().toRequestBody(JSON)
val request = Request.Builder().url(lanUrl(endpoint, CLAIM_PATH)).post(body).build()
val request = Request.Builder().url(under(base, CLAIM_PATH)).post(body).build()
// The code is single-use and wrong attempts are counted: never let the
// HTTP stack silently replay this POST.
val client =
pinned
val claimClient =
client
.newBuilder()
.retryOnConnectionFailure(false)
.callTimeout(CLAIM_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
return try {
client.newCall(request).await().use { response ->
claimClient.newCall(request).await().use { response ->
val json = readJson(response)
if (response.code == 200) {
parseClaim(json, endpoint, fingerprint)
} else {
AiturkPairResult.Failure(claimError(response.code, json?.string("error")))
val errorName = json?.string("error")
when {
response.code == 200 -> {
parseClaim(json, base, hostPort, endpoint, fingerprint)
}
else -> {
val error = claimError(response.code, errorName)
AiturkPairResult.Failure(error, "claim:${claimCode(error, response.code)}")
}
}
}
} catch (_: java.io.IOException) {
AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE)
} catch (e: IOException) {
// The claim may or may not have reached the computer: it is never
// retried and never re-sent over the other path.
if (viaRelay) {
val kind = AiturkDiag.tunnelKind(e)
AiturkPairResult.Failure(relayError(kind), "claim:relay_$kind")
} else {
val kind = AiturkDiag.networkKind(e)
AiturkPairResult.Failure(lanError(kind), "claim:lan_$kind")
}
}
}
private fun parseClaim(
json: JsonObject?,
endpoint: AiturkEndpoint,
base: HttpUrl,
hostPort: String,
endpoint: AiturkEndpoint?,
fingerprint: String,
): AiturkPairResult<AiturkPairedDevice> {
val token = json?.string("token")
if (token == null || !TOKEN_PATTERN.matches(token)) {
return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE)
return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "claim:${AiturkDiag.BAD_RESPONSE}")
}
return AiturkPairResult.Success(
AiturkPairedDevice(
baseUrl = base.toString(),
hostPort = hostPort,
endpoint = endpoint,
fingerprint = fingerprint,
token = token,
@@ -236,30 +516,34 @@ class AiturkPairingClient(
}
private fun parseOffer(json: JsonObject?): AiturkPairResult<AiturkPairingOffer> {
val bad = AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:${AiturkDiag.BAD_RESPONSE}")
val fingerprint = json?.string("fp")
val urls = (json?.get("urls") as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
if (fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint) || urls == null) {
return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
}
// The rendezvous record is untrusted input: only private IPv4 targets, 1..4.
if (json == null || fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint)) return bad
// The rendezvous record is untrusted input: only private IPv4 targets
// (0..4) and, optionally, a lowercase UUIDv4 relay id.
val urls =
when (val raw = json["urls"]) {
null, is JsonNull -> emptyList()
is JsonArray -> raw.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
else -> return bad
}
val endpoints = urls.map { it?.let(AiturkPairing::parseEndpointUrl) }
if (endpoints.isEmpty() || endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) {
return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
}
return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint))
if (endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) return bad
val relay =
when (val raw = json["relay"]) {
null, is JsonNull -> null
is JsonPrimitive -> raw.takeIf { it.isString }?.content?.let(AiturkRelay::parse) ?: return bad
else -> return bad
}
if (endpoints.isEmpty() && relay == null) return bad
return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint, relay))
}
private fun lanUrl(
endpoint: AiturkEndpoint,
/** [path] under [base], keeping whatever path prefix the base carries (the relay's). */
private fun under(
base: HttpUrl,
path: String,
): HttpUrl =
HttpUrl
.Builder()
.scheme("https")
.host(endpoint.host)
.port(endpoint.port)
.addPathSegments(path)
.build()
): HttpUrl = base.newBuilder().addPathSegments(path).build()
private fun readJson(response: Response): JsonObject? =
runCatching {
@@ -277,6 +561,7 @@ class AiturkPairingClient(
companion object {
const val DEFAULT_GATEWAY = "https://turkservis.online/"
const val HELLO_TIMEOUT_MS = 2_500L
const val RELAY_HELLO_TIMEOUT_MS = 10_000L
const val MAX_NAME_LENGTH = 60
private const val RESOLVE_PATH = "v1/aiturk/mobile-pair/resolve"
private const val HELLO_PATH = "aiturk/pair/hello"
@@ -289,6 +574,16 @@ class AiturkPairingClient(
private val JSON = "application/json; charset=utf-8".toMediaType()
private val TOKEN_PATTERN = Regex("^amd_[A-Za-z0-9_-]{43}$")
private val LAN_KIND_PRIORITY =
listOf(
AiturkDiag.PERMISSION,
AiturkDiag.TLS,
AiturkDiag.BAD_RESPONSE,
AiturkDiag.REFUSED,
AiturkDiag.UNREACHABLE,
AiturkDiag.TIMEOUT,
)
// A bare client on purpose: no cookie jar, no user-configured custom
// headers and no Authorization stamping reach the gateway or `claim`.
private val defaultBaseClient: OkHttpClient by lazy {
@@ -309,22 +604,70 @@ class AiturkPairingClient(
.take(MAX_NAME_LENGTH)
.ifBlank { "Android" }
/** Tunnel `hello`/`claim` failure kind → what the user is told (contract v3 §3). */
internal fun relayError(kind: String): AiturkPairError =
when (kind) {
AiturkTunnelException.HOST_OFFLINE -> AiturkPairError.HOST_OFFLINE
AiturkTunnelException.UNKNOWN -> AiturkPairError.RELAY_UNKNOWN
AiturkTunnelException.BUSY -> AiturkPairError.RELAY_BUSY
AiturkTunnelException.TIMEOUT -> AiturkPairError.HOST_TIMEOUT
AiturkTunnelException.NETWORK -> AiturkPairError.NO_INTERNET
AiturkDiag.PIN -> AiturkPairError.TLS_PIN
else -> AiturkPairError.RELAY_ERROR
}
/** LAN failure kind → what the user is told. */
internal fun lanError(kind: String): AiturkPairError =
if (kind == AiturkDiag.PERMISSION) AiturkPairError.LAN_PERMISSION else AiturkPairError.PC_UNREACHABLE
/** Of several failed LAN candidates, the one that says most about the cause. */
internal fun mostTelling(kinds: List<String>): String =
LAN_KIND_PRIORITY.firstOrNull { it in kinds }
?: kinds.firstOrNull { it.startsWith("http_") }
?: kinds.firstOrNull()
?: AiturkDiag.TIMEOUT
private fun claimCode(
error: AiturkPairError,
httpCode: Int,
): String =
when (error) {
AiturkPairError.INVALID_REQUEST -> "invalid_request"
AiturkPairError.WRONG_CODE -> "wrong_code"
AiturkPairError.PAIRING_CLOSED -> "pairing_closed"
AiturkPairError.PAIRING_LOCKED -> "pairing_locked"
else -> "http_$httpCode"
}
internal fun claimError(
httpCode: Int,
errorName: String?,
): AiturkPairError =
when (errorName) {
"invalid_request" -> AiturkPairError.INVALID_REQUEST
"wrong_code" -> AiturkPairError.WRONG_CODE
"pairing_closed" -> AiturkPairError.PAIRING_CLOSED
"pairing_locked" -> AiturkPairError.PAIRING_LOCKED
else ->
"invalid_request" -> {
AiturkPairError.INVALID_REQUEST
}
"wrong_code" -> {
AiturkPairError.WRONG_CODE
}
"pairing_closed" -> {
AiturkPairError.PAIRING_CLOSED
}
"pairing_locked" -> {
AiturkPairError.PAIRING_LOCKED
}
else -> {
when (httpCode) {
400, 413 -> AiturkPairError.INVALID_REQUEST
401 -> AiturkPairError.WRONG_CODE
410 -> AiturkPairError.PAIRING_CLOSED
else -> AiturkPairError.UNEXPECTED_RESPONSE
}
}
}
}
}
@@ -10,7 +10,11 @@ import java.util.Locale
* AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable.
*
* QR / deep-link payload:
* `aiturk://pair?v=1&u=<ip:port>[&u=<ip:port>… max 4]&fp=<43 chars>&c=<8 char code>`
* `aiturk://pair?v=1&fp=<43 chars>&c=<8 char code>[&r=<relayId>][&u=<ip:port>… max 4]`
*
* Contract v3 (tunnel): `r` is a lowercase UUIDv4 relay id and is what the IDE
* sends by default; `u` (0..4) is only present when the user allowed direct
* same-Wi-Fi connections. A payload with neither `u` nor `r` is rejected.
*/
data class AiturkEndpoint(
val host: String,
@@ -23,10 +27,14 @@ data class AiturkEndpoint(
val baseUrl: String get() = "https://$host:$port/"
}
/** A pairing offer: where the IDE listens and which certificate it must present. */
/**
* A pairing offer: where the IDE listens and which certificate it must present
* (on the LAN and inside the tunnel alike). [relay] is the validated relay id or null.
*/
data class AiturkPairingOffer(
val endpoints: List<AiturkEndpoint>,
val fingerprint: String,
val relay: String? = null,
)
data class AiturkPairingPayload(
@@ -50,9 +58,12 @@ enum class AiturkPayloadError {
/** A `u` entry is not `<private IPv4>:<port>`. */
BAD_ADDRESS,
/** No `u`, or more than [AiturkPairing.MAX_ENDPOINTS]. */
/** More than [AiturkPairing.MAX_ENDPOINTS] `u`, or neither `u` nor `r`. */
BAD_ADDRESS_COUNT,
/** `r` is present but is not a lowercase UUIDv4 relay id. */
BAD_RELAY,
/** `c` is not 8 characters of the pairing alphabet. */
BAD_CODE,
}
@@ -106,8 +117,18 @@ object AiturkPairing {
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT)
}
// Relay first: a present-but-wrong `r` rejects the whole payload, it is
// never silently dropped in favour of the LAN candidates.
val rawRelays = params.filter { it.first == "r" }.map { it.second }
val relay =
when (rawRelays.size) {
0 -> null
1 -> AiturkRelay.parse(rawRelays.single()) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
else -> return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
}
val rawEndpoints = params.filter { it.first == "u" }.map { it.second }
if (rawEndpoints.isEmpty() || rawEndpoints.size > MAX_ENDPOINTS) {
if (rawEndpoints.size > MAX_ENDPOINTS || (rawEndpoints.isEmpty() && relay == null)) {
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT)
}
val endpoints =
@@ -121,7 +142,7 @@ object AiturkPairing {
return AiturkPayloadResult.Ok(
AiturkPairingPayload(
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint),
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint, relay),
code = code,
),
)
@@ -194,3 +215,42 @@ object AiturkPairing {
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8")
}
/**
* The turkservis.online tunnel (contract v3). `r` / `relay` is a bare relay id
* (lowercase UUIDv4). A relay pairing is addressed by the VIRTUAL host
* `<relayId>.relay.aiturk.invalid` — a name that never resolves on the
* internet; [AiturkTunnel] turns connections to it into an end-to-end
* encrypted stream through the relay.
*/
object AiturkRelay {
const val HOST_SUFFIX = ".relay.aiturk.invalid"
const val PORT = 443
private const val UUID4 = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
private val relayId = Regex(UUID4)
/**
* Untrusted input (QR `r`, gateway `relay`): returns the value only when it
* is EXACTLY a lowercase UUIDv4 — no URL, host, path, upper case, braces or
* surrounding whitespace.
*/
fun parse(raw: String): String? = raw.takeIf { relayId.matches(it) }
fun virtualHost(relayId: String): String = relayId + HOST_SUFFIX
/** Key used by the certificate pin store for a relay pairing. */
fun hostPort(relayId: String): String = "${virtualHost(relayId)}:$PORT"
/** The saved connection base: no path prefix, trailing slash. */
fun baseUrl(relayId: String): String = "https://${virtualHost(relayId)}/"
/** The relay id behind a virtual host name, or null for any other host. */
fun relayIdOfHost(host: String?): String? =
host
?.takeIf { it.endsWith(HOST_SUFFIX) }
?.removeSuffix(HOST_SUFFIX)
?.let(::parse)
fun isVirtualHost(host: String?): Boolean = relayIdOfHost(host) != null
}
@@ -0,0 +1,715 @@
package com.m57.hermescontrol.data.aiturk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import okio.ByteString.Companion.toByteString
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.ProxySelector
import java.net.Socket
import java.net.SocketAddress
import java.net.SocketException
import java.net.SocketImpl
import java.net.SocketTimeoutException
import java.net.URI
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import javax.net.SocketFactory
import javax.net.ssl.SSLSocketFactory
/**
* End-to-end encrypted tunnel to a paired AITURK IDE through turkservis.online
* (contract v3, `aiturk-mobil-tunel-spec`).
*
* The relay only carries bytes. A paired relay connection has the virtual base
* address `https://<relayId>.relay.aiturk.invalid` (no path prefix). For such a
* host — and for no other — the shared OkHttp client:
* - resolves the name to a fixed placeholder address ([AiturkTunnelDns]);
* - opens `wss://turkservis.online/v1/aiturk/mobile-relay/t/<relayId>` instead
* of a TCP connection and exposes it as a socket ([AiturkTunnelSocket]);
* - runs TLS ON TOP of that socket, pinned to the IDE's certificate
* fingerprint ([AiturkLanTrust]). turkservis.online and anything in front of
* it see ciphertext only: no device token, path, chat text or file.
*/
object AiturkTunnel {
const val DEFAULT_RELAY_WS = "wss://turkservis.online/v1/aiturk/mobile-relay/t/"
const val MAX_FRAME_BYTES = 64 * 1024
/** How the byte stream is opened. Replaced by JVM tests with a local fake relay. */
@Volatile
internal var connector: AiturkTunnelConnector = WebSocketTunnelConnector()
/**
* The single hook for an OkHttp builder. Every host that is not a virtual
* relay host keeps the platform's DNS, plain TCP sockets and proxy choice.
*/
fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder = applyTo(builder) { connector }
internal fun applyTo(
builder: OkHttpClient.Builder,
connector: () -> AiturkTunnelConnector,
): OkHttpClient.Builder =
builder
.dns(AiturkTunnelDns())
.socketFactory(AiturkTunnelSocketFactory(connector))
.proxySelector(AiturkTunnelProxySelector())
}
/**
* Why a tunnel stream could not be opened or ended. [kind] is one of the fixed
* words below (it becomes the `relay:<kind>` diagnostic code).
*/
class AiturkTunnelException(
val kind: String,
val closeCode: Int? = null,
cause: Throwable? = null,
) : IOException("AITURK tunnel: $kind", cause) {
companion object {
const val HOST_OFFLINE = "host_offline"
const val UNKNOWN = "unknown"
const val BUSY = "busy"
const val TIMEOUT = "timeout"
const val OVERFLOW = "overflow"
const val NETWORK = "network"
const val REJECTED = "rejected"
const val CLOSED = "closed"
/** Relay close code → kind; null for a normal end of stream. */
fun kindForCloseCode(code: Int): String? =
when (code) {
1000 -> null
4503 -> HOST_OFFLINE
4404 -> UNKNOWN
4429 -> BUSY
4504 -> TIMEOUT
4408 -> OVERFLOW
4400, 4403 -> REJECTED
else -> CLOSED
}
fun find(error: Throwable?): AiturkTunnelException? =
generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }
.take(MAX_CAUSE_DEPTH)
.filterIsInstance<AiturkTunnelException>()
.firstOrNull()
private const val MAX_CAUSE_DEPTH = 8
}
}
/** An open byte stream to the IDE's TLS proxy. Blocking, like a socket. */
interface AiturkTunnelChannel {
val input: InputStream
val output: OutputStream
var soTimeoutMs: Int
val isClosed: Boolean
fun close()
}
fun interface AiturkTunnelConnector {
/** Blocks until the stream is open; throws [AiturkTunnelException] otherwise. */
@Throws(IOException::class)
fun open(
relayId: String,
timeoutMs: Int,
): AiturkTunnelChannel
}
/** `*.relay.aiturk.invalid` → placeholder address (hostname kept); everything else → [delegate]. */
class AiturkTunnelDns(
private val delegate: Dns = Dns.SYSTEM,
) : Dns {
override fun lookup(hostname: String): List<InetAddress> =
if (AiturkRelay.isVirtualHost(hostname)) {
listOf(InetAddress.getByAddress(hostname, PLACEHOLDER))
} else {
delegate.lookup(hostname)
}
companion object {
// TEST-NET-1 (RFC 5737): never routed, and never dialled by the tunnel socket.
internal val PLACEHOLDER = byteArrayOf(192.toByte(), 0, 2, 1)
}
}
/** Virtual relay hosts are never sent through a proxy; other hosts use the system selector. */
class AiturkTunnelProxySelector : ProxySelector() {
override fun select(uri: URI?): List<Proxy> {
if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return listOf(Proxy.NO_PROXY)
return runCatching { ProxySelector.getDefault()?.select(uri) }.getOrNull()?.takeIf { it.isNotEmpty() } ?: listOf(Proxy.NO_PROXY)
}
override fun connectFailed(
uri: URI?,
sa: SocketAddress?,
ioe: IOException?,
) {
if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return
runCatching { ProxySelector.getDefault()?.connectFailed(uri, sa, ioe) }
}
}
/**
* OkHttp asks for an unconnected socket and only then names the peer, so the
* socket decides in `connect()`: a virtual relay host becomes a tunnel stream,
* any other address is an ordinary TCP connection made by [java.net.Socket]
* itself.
*/
class AiturkTunnelSocketFactory internal constructor(
private val connector: () -> AiturkTunnelConnector,
) : SocketFactory() {
override fun createSocket(): Socket = AiturkTunnelSocket(connector)
override fun createSocket(
host: String?,
port: Int,
): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(
host: String?,
port: Int,
localHost: InetAddress?,
localPort: Int,
): Socket = createSocket(host, port)
override fun createSocket(
host: InetAddress?,
port: Int,
): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(
address: InetAddress?,
port: Int,
localAddress: InetAddress?,
localPort: Int,
): Socket = createSocket(address, port)
}
/** A plain TCP socket unless it is connected to a virtual relay host. */
class AiturkTunnelSocket internal constructor(
private val connector: () -> AiturkTunnelConnector,
) : Socket() {
@Volatile
internal var channel: AiturkTunnelChannel? = null
private set
@Volatile
internal var tunnelPeer: InetSocketAddress? = null
private set
@Volatile
private var requestedSoTimeout = 0
override fun connect(endpoint: SocketAddress?) = connect(endpoint, 0)
override fun connect(
endpoint: SocketAddress?,
timeout: Int,
) {
val peer = endpoint as? InetSocketAddress
val relayId = peer?.hostString?.let(AiturkRelay::relayIdOfHost)
if (peer == null || relayId == null) {
super.connect(endpoint, timeout)
return
}
if (channel != null) throw SocketException("Already connected")
val opened = connector().open(relayId, if (timeout > 0) timeout else DEFAULT_CONNECT_TIMEOUT_MS)
opened.soTimeoutMs = requestedSoTimeout
tunnelPeer = peer
channel = opened
}
override fun getInputStream(): InputStream = channel?.input ?: super.getInputStream()
override fun getOutputStream(): OutputStream = channel?.output ?: super.getOutputStream()
override fun setSoTimeout(timeout: Int) {
requestedSoTimeout = timeout
val tunnel = channel
if (tunnel != null) tunnel.soTimeoutMs = timeout else super.setSoTimeout(timeout)
}
override fun getSoTimeout(): Int = channel?.soTimeoutMs ?: super.getSoTimeout()
override fun setTcpNoDelay(on: Boolean) {
if (channel == null) super.setTcpNoDelay(on)
}
override fun setKeepAlive(on: Boolean) {
if (channel == null) super.setKeepAlive(on)
}
override fun isConnected(): Boolean = channel != null || super.isConnected()
override fun isClosed(): Boolean = channel?.isClosed ?: super.isClosed()
override fun isInputShutdown(): Boolean = if (channel != null) false else super.isInputShutdown()
override fun isOutputShutdown(): Boolean = if (channel != null) false else super.isOutputShutdown()
override fun getInetAddress(): InetAddress? = tunnelPeer?.address ?: super.getInetAddress()
override fun getPort(): Int = tunnelPeer?.port ?: super.getPort()
override fun getRemoteSocketAddress(): SocketAddress? = tunnelPeer ?: super.getRemoteSocketAddress()
override fun shutdownInput() {
if (channel == null) super.shutdownInput()
}
override fun shutdownOutput() {
if (channel == null) super.shutdownOutput()
}
override fun close() {
channel?.close()
super.close()
}
private companion object {
const val DEFAULT_CONNECT_TIMEOUT_MS = 10_000
}
}
/**
* The view of a tunnel that is handed to the TLS layer: a socket WITHOUT a
* platform socket implementation (and so without a file descriptor). Android's
* TLS provider then takes its stream-based path and reads/writes through
* [getInputStream] / [getOutputStream] instead of a native descriptor.
*/
internal class AiturkTunnelStreamSocket(
private val owner: AiturkTunnelSocket,
) : Socket(null as SocketImpl?) {
private val tunnel: AiturkTunnelChannel get() = owner.channel ?: throw SocketException("Socket is not connected")
override fun getInputStream(): InputStream = tunnel.input
override fun getOutputStream(): OutputStream = tunnel.output
override fun setSoTimeout(timeout: Int) = owner.setSoTimeout(timeout)
override fun getSoTimeout(): Int = owner.soTimeout
override fun isConnected(): Boolean = true
override fun isBound(): Boolean = true
override fun isClosed(): Boolean = owner.isClosed
override fun isInputShutdown(): Boolean = false
override fun isOutputShutdown(): Boolean = false
override fun getInetAddress(): InetAddress? = owner.inetAddress
override fun getPort(): Int = owner.port
override fun getRemoteSocketAddress(): SocketAddress? = owner.remoteSocketAddress
override fun getLocalAddress(): InetAddress = InetAddress.getByAddress(byteArrayOf(0, 0, 0, 0))
override fun getLocalPort(): Int = 0
override fun getLocalSocketAddress(): SocketAddress = InetSocketAddress(localAddress, 0)
override fun setTcpNoDelay(on: Boolean) = Unit
override fun getTcpNoDelay(): Boolean = true
override fun setKeepAlive(on: Boolean) = Unit
override fun getKeepAlive(): Boolean = false
override fun setSoLinger(
on: Boolean,
linger: Int,
) = Unit
override fun getSoLinger(): Int = -1
override fun setSendBufferSize(size: Int) = Unit
override fun getSendBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES
override fun setReceiveBufferSize(size: Int) = Unit
override fun getReceiveBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES
override fun shutdownInput() = Unit
override fun shutdownOutput() = Unit
override fun close() = owner.close()
override fun toString(): String = "AiturkTunnelStreamSocket"
}
/**
* Wraps a TLS socket factory so that TLS over a tunnel is layered on the
* descriptor-less [AiturkTunnelStreamSocket]. Every other socket is passed to
* [delegate] untouched.
*/
internal class AiturkTunnelSslSocketFactory(
private val delegate: SSLSocketFactory,
) : SSLSocketFactory() {
override fun createSocket(
s: Socket?,
host: String?,
port: Int,
autoClose: Boolean,
): Socket =
if (s is AiturkTunnelSocket && s.channel != null) {
// Closing the TLS socket must always close the tunnel stream.
delegate.createSocket(AiturkTunnelStreamSocket(s), host, port, true)
} else {
delegate.createSocket(s, host, port, autoClose)
}
override fun getDefaultCipherSuites(): Array<String> = delegate.defaultCipherSuites
override fun getSupportedCipherSuites(): Array<String> = delegate.supportedCipherSuites
override fun createSocket(): Socket = delegate.createSocket()
override fun createSocket(
host: String?,
port: Int,
): Socket = delegate.createSocket(host, port)
override fun createSocket(
host: String?,
port: Int,
localHost: InetAddress?,
localPort: Int,
): Socket = delegate.createSocket(host, port, localHost, localPort)
override fun createSocket(
host: InetAddress?,
port: Int,
): Socket = delegate.createSocket(host, port)
override fun createSocket(
address: InetAddress?,
port: Int,
localAddress: InetAddress?,
localPort: Int,
): Socket = delegate.createSocket(address, port, localAddress, localPort)
}
/**
* Opens the stream as a WebSocket to the relay with a separate, bare OkHttp
* client: platform TLS, no cookie jar, no custom headers, no `Origin`.
* Binary frames are raw bytes in both directions.
*/
class WebSocketTunnelConnector(
private val relayWsBase: String = AiturkTunnel.DEFAULT_RELAY_WS,
client: OkHttpClient? = null,
) : AiturkTunnelConnector {
private val client: OkHttpClient by lazy {
client ?: OkHttpClient
.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(PING_SECONDS, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build()
}
override fun open(
relayId: String,
timeoutMs: Int,
): AiturkTunnelChannel {
// Only a validated relay id is ever placed in the URL.
val id = AiturkRelay.parse(relayId) ?: throw AiturkTunnelException(AiturkTunnelException.UNKNOWN)
val channel = WebSocketTunnelChannel()
val socket = client.newWebSocket(Request.Builder().url(relayWsBase + id).build(), channel.listener)
channel.attach(socket)
val opened =
try {
channel.awaitOpen(timeoutMs.toLong())
} catch (e: InterruptedException) {
socket.cancel()
Thread.currentThread().interrupt()
throw AiturkTunnelException(AiturkTunnelException.NETWORK, cause = e)
}
if (!opened) {
socket.cancel()
throw channel.failure ?: AiturkTunnelException(AiturkTunnelException.NETWORK)
}
return channel
}
private companion object {
const val PING_SECONDS = 25L
}
}
internal class WebSocketTunnelChannel : AiturkTunnelChannel {
private val incoming = LinkedBlockingQueue<ByteString>()
private val openLatch = CountDownLatch(1)
private val flow = Object()
@Volatile
private var socket: WebSocket? = null
@Volatile
private var isOpen = false
@Volatile
private var ended = false
@Volatile
private var closedLocally = false
@Volatile
internal var failure: AiturkTunnelException? = null
private set
private var queuedBytes = 0L
@Volatile
override var soTimeoutMs: Int = 0
override val isClosed: Boolean get() = closedLocally
fun attach(webSocket: WebSocket) {
socket = webSocket
}
/** True when the relay accepted the stream; false when it failed or timed out. */
fun awaitOpen(timeoutMs: Long): Boolean = openLatch.await(timeoutMs, TimeUnit.MILLISECONDS) && isOpen
val listener: WebSocketListener =
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
isOpen = true
openLatch.countDown()
}
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) {
if (bytes.size == 0) return
synchronized(flow) {
// Applies back-pressure to the relay connection instead of
// buffering without bound when the reader is slow.
while (queuedBytes > MAX_BUFFERED_BYTES && !ended && !closedLocally) flow.wait(FLOW_WAIT_MS)
queuedBytes += bytes.size
}
incoming.put(bytes)
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
// The stream is binary only.
end(AiturkTunnelException(AiturkTunnelException.REJECTED))
webSocket.cancel()
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) })
webSocket.close(NORMAL_CLOSE, null)
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) = end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) })
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
// An HTTP answer instead of the upgrade means the relay refused
// the stream; anything else is the network.
val kind = if (response != null) AiturkTunnelException.REJECTED else AiturkTunnelException.NETWORK
end(AiturkTunnelException(kind, cause = t))
}
}
private fun end(error: AiturkTunnelException?) {
synchronized(flow) {
if (ended) return
if (error != null && !closedLocally) failure = error
ended = true
flow.notifyAll()
}
incoming.offer(END)
openLatch.countDown()
}
override val input: InputStream =
object : InputStream() {
private var current: ByteString? = null
private var position = 0
override fun read(): Int {
val one = ByteArray(1)
return if (read(one, 0, 1) == -1) -1 else one[0].toInt() and BYTE_MASK
}
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
if (len == 0) return 0
var chunk = current
if (chunk == null) {
chunk = next() ?: return -1
current = chunk
position = 0
}
val count = minOf(len, chunk.size - position)
chunk.copyInto(position, b, off, count)
position += count
if (position == chunk.size) current = null
synchronized(flow) {
queuedBytes -= count
flow.notifyAll()
}
return count
}
override fun available(): Int = current?.let { it.size - position } ?: 0
override fun close() = this@WebSocketTunnelChannel.close()
/** The next chunk, or null at a clean end of stream. */
private fun next(): ByteString? {
if (closedLocally) throw SocketException("Socket closed")
val timeout = soTimeoutMs
val chunk =
try {
if (timeout > 0) incoming.poll(timeout.toLong(), TimeUnit.MILLISECONDS) else incoming.take()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw java.io.InterruptedIOException("interrupted")
} ?: throw SocketTimeoutException("Read timed out")
if (chunk !== END) return chunk
incoming.offer(END)
if (closedLocally) throw SocketException("Socket closed")
failure?.let { throw AiturkTunnelException(it.kind, it.closeCode, it) }
return null
}
}
override val output: OutputStream =
object : OutputStream() {
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
override fun write(
b: ByteArray,
off: Int,
len: Int,
) {
var offset = off
var remaining = len
while (remaining > 0) {
val count = minOf(remaining, AiturkTunnel.MAX_FRAME_BYTES)
send(b.toByteString(offset, count))
offset += count
remaining -= count
}
}
override fun close() = this@WebSocketTunnelChannel.close()
}
private fun send(frame: ByteString) {
val webSocket = socket ?: throw SocketException("Socket is not connected")
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WRITE_STALL_MS)
// Keep the outbound queue small: the relay drops a stream whose
// pending data grows past its limit.
while (webSocket.queueSize() > MAX_PENDING_WRITE_BYTES) {
checkWritable()
if (System.nanoTime() > deadline) throw SocketTimeoutException("Write timed out")
try {
Thread.sleep(WRITE_POLL_MS)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw java.io.InterruptedIOException("interrupted")
}
}
checkWritable()
if (!webSocket.send(frame)) {
checkWritable()
throw AiturkTunnelException(AiturkTunnelException.CLOSED)
}
}
private fun checkWritable() {
if (closedLocally) throw SocketException("Socket closed")
if (ended) throw failure?.let { AiturkTunnelException(it.kind, it.closeCode, it) } ?: SocketException("Connection closed by peer")
}
override fun close() {
synchronized(flow) {
if (closedLocally) return
closedLocally = true
flow.notifyAll()
}
val webSocket = socket
if (webSocket != null && !webSocket.close(NORMAL_CLOSE, null)) webSocket.cancel()
incoming.offer(END)
openLatch.countDown()
}
private companion object {
val END: ByteString = ByteString.of(0)
const val NORMAL_CLOSE = 1000
const val BYTE_MASK = 0xFF
const val MAX_BUFFERED_BYTES = 1L * 1024 * 1024
const val MAX_PENDING_WRITE_BYTES = 256L * 1024
const val FLOW_WAIT_MS = 50L
const val WRITE_POLL_MS = 2L
const val WRITE_STALL_MS = 30_000L
}
}
/**
* What the tunnel last said about the computer behind a paired connection, for
* the chat's connection banner: "reconnecting…" alone does not tell the user
* that AITURK IDE is closed.
*/
object AiturkRelayStatus {
private val _hostOffline = MutableStateFlow(false)
val hostOffline: StateFlow<Boolean> = _hostOffline.asStateFlow()
/** [error] is what ended (or prevented) the app's WebSocket; null clears the note. */
fun onSocketEnded(error: Throwable?) {
_hostOffline.value = AiturkTunnelException.find(error)?.kind == AiturkTunnelException.HOST_OFFLINE
}
fun onSocketOpened() {
_hostOffline.value = false
AiturkCompat.onSocketOpened()
}
}
@@ -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)
}
}
@@ -47,6 +47,10 @@ fun mapHttpError(
if (code == 401) {
return NetworkError.AuthExpired()
}
// AITURK: the IDE's phone proxy refusing a route, or the IDE not having it, gets a plain sentence.
com.m57.hermescontrol.data.aiturk.AiturkCompat
.httpErrorMessage(code, errorBody)
?.let { return NetworkError.Http(code, it) }
// Try to parse FastAPI-style error detail from response body
val detail =
if (errorBody != null) {
@@ -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))
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.aiturk.AiturkCompat
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -110,6 +111,11 @@ internal class RpcChannel(
suppressErrorEvent: Boolean = false,
): CompletableDeferred<Any?> {
val deferred = CompletableDeferred<Any?>()
// AITURK: a method the paired IDE already answered "unknown method" for is not sent again.
if (AiturkCompat.isKnownMissing(method)) {
deferred.completeExceptionally(AiturkCompat.missingMethodError())
return deferred
}
val id =
sendRequest(method, params) { reqId ->
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
@@ -191,7 +197,7 @@ internal class RpcChannel(
if (error != null) {
call.deferred.completeExceptionally(
HermesWsClient.HermesRpcException(
message = error.message,
message = AiturkCompat.rpcExceptionMessage(error.code, error.message),
code = error.code,
data = error.data,
),
@@ -332,7 +338,12 @@ internal class RpcChannel(
is WsEvent.RpcError -> {
onError(event.id)
if (pendingCalls[event.id]?.suppressErrorEvent == true) {
// AITURK: an awaited call the paired IDE does not know fails through its
// deferred only; the raw "unknown method" never reaches the chat banner.
val pending = pendingCalls[event.id]
AiturkCompat.noteRpcError(pending?.method, event.error.code)
val quiet = pending != null && AiturkCompat.quietRpcError(event.error.code)
if (quiet || pending?.suppressErrorEvent == true) {
// Opt-in suppression: the caller already handles the
// failure through the CompletableDeferred, so the event
// copy would only surface a duplicate UI banner for
@@ -659,6 +659,8 @@ internal class WsTransport(
outboundDrainJob?.cancel()
outboundDrainJob = null
Log.i(TAG, "WebSocket opened")
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
.onSocketOpened()
connected.set(true)
_connectionStatus.value = ConnectionStatus.CONNECTED
reconnectPolicy.reset()
@@ -730,6 +732,8 @@ internal class WsTransport(
// Do NOT log [reason] — it may carry server-side context. The
// reason is still inspected internally to detect auth failures.
Log.i(TAG, "WebSocket closed: $code")
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
.onSocketEnded(null)
connected.set(false)
ActiveSessionHolder.clear()
connectionHealth.stop()
@@ -764,6 +768,10 @@ internal class WsTransport(
connectionHealth.stop()
rpcChannel.clearCapabilityRequests()
val code = response?.code ?: 0
// AITURK: lets the chat banner say "AITURK IDE is not open on the
// computer" when the tunnel reports that no host is attached.
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
.onSocketEnded(t)
if (code == 401 || code == 4401 || code == 4403 ||
t.message?.contains("401") == true ||
t.message?.contains("4401") == true ||
@@ -726,6 +726,11 @@ object NotificationHelper {
) {
ChatNotificationService.setAppForeground(foreground)
HermesWsClient.setAppForeground(foreground)
// AITURK: reopen the socket that was closed while idle in the background.
if (foreground) {
com.m57.hermescontrol.data.aiturk.AiturkCompat
.onAppForeground()
}
if (!foreground) {
BackgroundConnectionController.default.reconcileState()
}
@@ -0,0 +1,60 @@
package com.m57.hermescontrol.ui.aiturkpair
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.navigation3.runtime.NavKey
import com.m57.hermescontrol.AccountConnectorsScreen
import com.m57.hermescontrol.FilesScreen
import com.m57.hermescontrol.KeysScreen
import com.m57.hermescontrol.R
import com.m57.hermescontrol.SettingsVault
import com.m57.hermescontrol.data.aiturk.AiturkCompat
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
/**
* Screens a paired AITURK IDE cannot serve, hidden from the drawer and the
* settings list while that pairing is the active connection:
*
* - Account connectors, Credential vault: the IDE's agent has no
* `connectors.*` / `vault.*` methods;
* - Keys, Files: the IDE's phone proxy refuses `/api/env` and `/api/files`
* on purpose (secrets and the computer's files stay on the computer).
*
* A plain Hermes dashboard connection shows everything, as upstream does.
*/
object AiturkFeatureGate {
val HIDDEN_WHEN_LINKED: Set<NavKey> = setOf(AccountConnectorsScreen, KeysScreen, FilesScreen, SettingsVault)
fun shows(
key: NavKey,
linked: Boolean = AiturkCompat.isLinked(),
): Boolean = !(linked && key in HIDDEN_WHEN_LINKED)
}
/** Shown instead of a hidden screen that is still reached (restored back stack, shortcut). */
@Composable
fun AiturkUnavailableScreen(
@StringRes titleRes: Int,
onOpenDrawer: (() -> Unit)?,
) {
HermesScaffold(
title = { Text(stringResource(titleRes)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
) { padding ->
EmptyState(
title = stringResource(R.string.aiturk_feature_unavailable),
subtitle = stringResource(R.string.aiturk_feature_unavailable_hint),
icon = Icons.Filled.Computer,
modifier = Modifier.padding(padding).testTag("aiturk_unavailable"),
)
}
}
@@ -43,6 +43,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -61,6 +62,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.aiturk.AiturkCompat
import com.m57.hermescontrol.data.aiturk.AiturkPairing
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
@@ -106,8 +108,24 @@ fun AiturkPairScreen(
LaunchedEffect(state.lanPermissionNeeded) {
if (state.lanPermissionNeeded) lanPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
LaunchedEffect(state.connectionSuccess) {
if (state.connectionSuccess) onConnected()
// The connection is already saved; the dialog only shows the security code
// (the same 8 characters AITURK IDE shows under the QR code) before leaving.
if (state.connectionSuccess) {
val securityCode = state.securityCode
if (securityCode == null) {
LaunchedEffect(Unit) { onConnected() }
} else {
AlertDialog(
onDismissRequest = onConnected,
title = { Text(stringResource(R.string.aiturk_pair_security_title)) },
text = { Text(stringResource(R.string.aiturk_pair_security_message, securityCode)) },
confirmButton = {
TextButton(onClick = onConnected) {
Text(stringResource(R.string.aiturk_pair_security_ok))
}
},
)
}
}
val deepLink by AiturkPairDeepLink.pending.collectAsState()
LaunchedEffect(deepLink) {
@@ -256,11 +274,29 @@ fun AiturkPairScreen(
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
// Diagnostic code for support: fixed words only, never a token, code or address.
if (state.errorCode.isNotEmpty()) {
Text(
text = stringResource(R.string.aiturk_pair_error_code, state.errorCode),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
textAlign = TextAlign.Center,
)
}
}
TextButton(onClick = onBack, enabled = !state.busy) {
Text(stringResource(R.string.aiturk_pair_back))
}
// So the user can tell which build is installed when support asks.
Text(
text = stringResource(R.string.aiturk_app_version, AiturkCompat.versionLabel()),
modifier = Modifier.testTag("aiturk_app_version"),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
)
}
}
}
@@ -5,12 +5,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.aiturk.AiturkConnectionRegistrar
import com.m57.hermescontrol.data.aiturk.AiturkDiag
import com.m57.hermescontrol.data.aiturk.AiturkPairError
import com.m57.hermescontrol.data.aiturk.AiturkPairResult
import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice
import com.m57.hermescontrol.data.aiturk.AiturkPairing
import com.m57.hermescontrol.data.aiturk.AiturkPairingClient
import com.m57.hermescontrol.data.aiturk.AiturkPairingOffer
import com.m57.hermescontrol.data.aiturk.AiturkPairingPayload
import com.m57.hermescontrol.data.aiturk.AiturkPayloadError
import com.m57.hermescontrol.data.aiturk.AiturkPayloadResult
@@ -27,10 +27,14 @@ data class AiturkPairUiState(
val code: String = "",
val step: AiturkPairStep = AiturkPairStep.IDLE,
@param:StringRes val errorRes: Int? = null,
/** Short diagnostic (`relay:host_offline`, `lan:timeout` …) shown in small print under the error. */
val errorCode: String = "",
/** Address of a deep-link pairing waiting for the user's confirmation. */
val confirmAddress: String? = null,
val lanPermissionNeeded: Boolean = false,
val connectionSuccess: Boolean = false,
/** First 8 characters of the pinned certificate fingerprint; the IDE shows the same. */
val securityCode: String? = null,
) {
val busy: Boolean get() = step != AiturkPairStep.IDLE
}
@@ -53,11 +57,11 @@ class AiturkPairViewModel(
private val _uiState = MutableStateFlow(AiturkPairUiState())
val uiState: StateFlow<AiturkPairUiState> = _uiState.asStateFlow()
private var pending: Request? = null
private var pending: AiturkPairingPayload? = null
private var confirmPayload: AiturkPairingPayload? = null
fun onCodeChange(raw: String) {
_uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null) }
_uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null, errorCode = "") }
}
fun submitCode(lanPermissionGranted: Boolean) {
@@ -87,11 +91,12 @@ class AiturkPairViewModel(
when (val result = AiturkPairing.parse(raw)) {
is AiturkPayloadResult.Ok -> {
confirmPayload = result.payload
// A relay-only link (the default) has no LAN address to show.
val address =
result.payload.offer.endpoints
.first()
.hostPort
_uiState.update { it.copy(confirmAddress = address, errorRes = null) }
.firstOrNull()
?.hostPort ?: RELAY_DISPLAY_NAME
_uiState.update { it.copy(confirmAddress = address, errorRes = null, errorCode = "") }
}
is AiturkPayloadResult.Invalid -> {
@@ -115,13 +120,12 @@ class AiturkPairViewModel(
fun onLanPermissionResult(granted: Boolean) {
_uiState.update { it.copy(lanPermissionNeeded = false) }
val request = pending
val payload = pending
pending = null
if (!granted) {
showError(R.string.aiturk_pair_error_lan_permission)
} else if (request != null) {
run(request)
}
// A denied permission no longer stops the pairing: the tunnel needs no
// local-network access. Only the LAN candidates are skipped, and if
// nothing else works the result says so (`lan:permission`).
if (payload != null) run(payload, lanAllowed = granted)
}
/** The ViewModel outlives the screen; reset what must not replay on re-entry. */
@@ -131,9 +135,11 @@ class AiturkPairViewModel(
_uiState.update {
it.copy(
errorRes = null,
errorCode = "",
confirmAddress = null,
lanPermissionNeeded = false,
connectionSuccess = false,
securityCode = null,
)
}
}
@@ -143,26 +149,69 @@ class AiturkPairViewModel(
lanPermissionGranted: Boolean,
) {
if (_uiState.value.busy) return
if (!lanPermissionGranted) {
pending = request
_uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null) }
return
when (request) {
is Request.Payload -> {
connect(request.payload, lanPermissionGranted)
}
is Request.Code -> {
_uiState.update { it.copy(step = AiturkPairStep.RESOLVING, errorRes = null, errorCode = "") }
viewModelScope.launch {
val resolved =
try {
client.resolve(request.code)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
}
_uiState.update { it.copy(step = AiturkPairStep.IDLE) }
when (resolved) {
is AiturkPairResult.Success -> {
connect(AiturkPairingPayload(resolved.value, request.code), lanPermissionGranted)
}
is AiturkPairResult.Failure -> {
showFailure(resolved)
}
}
}
}
}
run(request)
}
private fun run(request: Request) {
/**
* The local-network permission is asked for only when a LAN candidate may
* actually be tried. A pairing that carries only a relay id (the default)
* goes through the tunnel and never asks.
*/
private fun connect(
payload: AiturkPairingPayload,
lanPermissionGranted: Boolean,
) {
if (_uiState.value.busy) return
val firstStep = if (request is Request.Code) AiturkPairStep.RESOLVING else AiturkPairStep.CONNECTING
_uiState.update { it.copy(step = firstStep, errorRes = null) }
if (!lanPermissionGranted && payload.offer.endpoints.isNotEmpty()) {
pending = payload
_uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null, errorCode = "") }
return
}
run(payload, lanAllowed = lanPermissionGranted)
}
private fun run(
payload: AiturkPairingPayload,
lanAllowed: Boolean,
) {
if (_uiState.value.busy) return
_uiState.update { it.copy(step = AiturkPairStep.CONNECTING, errorRes = null, errorCode = "") }
viewModelScope.launch {
val outcome =
try {
pair(request)
client.pair(payload.offer, payload.code, deviceName, lanAllowed)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE)
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
}
when (outcome) {
is AiturkPairResult.Success -> {
@@ -172,46 +221,43 @@ class AiturkPairViewModel(
step = AiturkPairStep.IDLE,
code = "",
connectionSuccess = saved,
securityCode = if (saved) securityCode(outcome.value.fingerprint) else null,
errorRes = if (saved) null else R.string.aiturk_pair_error_save_failed,
errorCode = if (saved) "" else "app:save_failed",
)
}
}
is AiturkPairResult.Failure -> {
_uiState.update { it.copy(step = AiturkPairStep.IDLE, errorRes = pairErrorRes(outcome.error)) }
showFailure(outcome)
}
}
}
}
private suspend fun pair(request: Request): AiturkPairResult<AiturkPairedDevice> {
val offer: AiturkPairingOffer
val code: String
when (request) {
is Request.Payload -> {
offer = request.payload.offer
code = request.payload.code
}
is Request.Code -> {
code = request.code
when (val resolved = client.resolve(code)) {
is AiturkPairResult.Success -> offer = resolved.value
is AiturkPairResult.Failure -> return resolved
}
_uiState.update { it.copy(step = AiturkPairStep.CONNECTING) }
}
private fun showFailure(failure: AiturkPairResult.Failure) {
_uiState.update {
it.copy(
step = AiturkPairStep.IDLE,
errorRes = pairErrorRes(failure.error),
errorCode = AiturkDiag.sanitize(failure.code),
)
}
return client.pair(offer, code, deviceName)
}
private fun showError(
@StringRes res: Int,
) {
_uiState.update { it.copy(errorRes = res) }
_uiState.update { it.copy(errorRes = res, errorCode = "") }
}
companion object {
const val SECURITY_CODE_LENGTH = 8
private const val RELAY_DISPLAY_NAME = "turkservis.online"
/** What both screens show so the user can compare them. */
fun securityCode(fingerprint: String): String = fingerprint.take(SECURITY_CODE_LENGTH)
@StringRes
fun payloadErrorRes(error: AiturkPayloadError): Int =
when (error) {
@@ -223,6 +269,7 @@ class AiturkPairViewModel(
AiturkPayloadError.BAD_FINGERPRINT,
AiturkPayloadError.BAD_ADDRESS,
AiturkPayloadError.BAD_ADDRESS_COUNT,
AiturkPayloadError.BAD_RELAY,
AiturkPayloadError.BAD_CODE,
-> R.string.aiturk_pair_error_qr_invalid
}
@@ -238,6 +285,22 @@ class AiturkPairViewModel(
AiturkPairError.PC_UNREACHABLE -> R.string.aiturk_pair_error_pc_unreachable
AiturkPairError.HOST_OFFLINE -> R.string.aiturk_pair_error_host_offline
AiturkPairError.HOST_TIMEOUT -> R.string.aiturk_pair_error_host_timeout
AiturkPairError.RELAY_BUSY -> R.string.aiturk_pair_error_relay_busy
AiturkPairError.RELAY_UNKNOWN -> R.string.aiturk_pair_error_relay_unknown
AiturkPairError.TLS_PIN -> R.string.aiturk_pair_error_tls_pin
AiturkPairError.NO_INTERNET -> R.string.aiturk_pair_error_no_internet
AiturkPairError.RELAY_ERROR -> R.string.aiturk_pair_error_relay
AiturkPairError.LAN_PERMISSION -> R.string.aiturk_pair_error_lan_permission
AiturkPairError.WRONG_CODE -> R.string.aiturk_pair_error_wrong_code
AiturkPairError.PAIRING_CLOSED -> R.string.aiturk_pair_error_pairing_closed
@@ -79,6 +79,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.AccountConnectorsScreen
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.HistoryScreen
@@ -94,6 +95,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
@@ -601,20 +603,23 @@ fun ChatScreen(
modifier = Modifier.testTag("chat_menu_search"),
)
DropdownMenuItem(
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
)
},
onClick = {
showSessionMenu = false
connectorsViewModel.show()
},
modifier = Modifier.testTag("chat_menu_session_integrations"),
)
// AITURK: the paired IDE has no connectors; the entry would only open an empty sheet.
if (AiturkFeatureGate.shows(AccountConnectorsScreen)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
)
},
onClick = {
showSessionMenu = false
connectorsViewModel.show()
},
modifier = Modifier.testTag("chat_menu_session_integrations"),
)
}
ChatBackgroundConnectionMenuItem(
checked = settingsState.keepConnectedInBackground,
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
@@ -5,6 +5,7 @@ import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.aiturk.AiturkCompat
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
@@ -2317,7 +2318,7 @@ class ChatViewModel(
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Error ($method): $errorMsg",
errorMessage = AiturkCompat.rpcBannerText(error, legacy = "Error ($method): $errorMsg"),
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
)
}
@@ -6038,6 +6039,8 @@ class ChatViewModel(
) {
return@launch
}
// AITURK: an unsaved (empty) chat has no session row yet; skip the 404 poll.
if (AiturkCompat.skipSessionDetail(_uiState.value.messages.isEmpty())) return@launch
val usedResult =
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
coroutineContext.ensureActive()
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.aiturk.AiturkCompat
import com.m57.hermescontrol.data.model.deltaFrom
import com.m57.hermescontrol.data.model.mergeWith
import com.m57.hermescontrol.data.model.parseUsageSnapshot
@@ -997,7 +998,7 @@ object ChatWsEventReducer {
state =
state.copy(
isLoading = false,
errorMessage = "Error: ${event.error}",
errorMessage = AiturkCompat.rpcBannerText(event.error, legacy = "Error: ${event.error}"),
),
streamingState = streamingState,
)
@@ -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 ->
@@ -17,6 +17,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -70,13 +72,20 @@ fun ChatConnectionBanner(
color = LocalHermesStatusColors.current.error,
)
}
// AITURK: over the relay, say why the reconnect keeps failing.
val aiturkHostOffline by com.m57.hermescontrol.data.aiturk.AiturkRelayStatus.hostOffline
.collectAsState()
val hostOffline = aiturkHostOffline && connectionStatus == ConnectionStatus.RECONNECTING
Text(
modifier = Modifier.weight(1f, fill = false),
maxLines = 1,
maxLines = if (hostOffline) 3 else 1,
overflow = TextOverflow.Ellipsis,
text =
when (connectionStatus) {
ConnectionStatus.RECONNECTING -> stringResource(R.string.chat_status_reconnecting)
ConnectionStatus.RECONNECTING ->
stringResource(
if (hostOffline) R.string.aiturk_status_host_offline else R.string.chat_status_reconnecting,
)
ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_disconnected)
ConnectionStatus.NO_NETWORK -> stringResource(R.string.chat_status_no_network)
ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired)
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.cron
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -16,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Pause
@@ -32,12 +35,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -46,9 +53,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -388,6 +397,12 @@ fun CronJobEditorDialog(
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
LaunchedEffect(state.isNew) {
if (state.isNew && state.schedule.isBlank()) {
onFieldChange("schedule", "0 9 * * *")
}
}
Dialog(
onDismissRequest = {
if (hasChanges && !state.isNew) {
@@ -416,6 +431,8 @@ fun CronJobEditorDialog(
} else {
stringResource(R.string.cron_edit_title)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon =
@@ -430,13 +447,13 @@ fun CronJobEditorDialog(
),
actions = {
if (!state.isLoading) {
IconButton(
TextButton(
onClick = onSave,
enabled = !state.isSaving,
) {
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(0.6f),
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -504,13 +521,9 @@ fun CronJobEditorDialog(
)
// Schedule (required)
OutlinedTextField(
value = state.schedule,
onValueChange = { onFieldChange("schedule", it) },
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
SchedulePickerSection(
schedule = state.schedule,
onScheduleChange = { onFieldChange("schedule", it) },
)
// Prompt
@@ -818,6 +831,7 @@ private fun BlueprintStartFromDropdown(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BlueprintSlotField(
field: CronBlueprintField,
@@ -837,13 +851,61 @@ private fun BlueprintSlotField(
)
}
field.type == "enum" || field.type == "weekdays" -> {
ExposedDropdownField(
label = field.label,
options = field.options,
selectedValue = value,
onOptionSelected = onValueChange,
)
field.type == "enum" || field.type == "weekdays" -> {
ExposedDropdownField(
label = field.label,
options = field.options,
selectedValue = value,
onOptionSelected = onValueChange,
)
}
field.type == "time" -> {
var showTimePicker by remember { mutableStateOf(false) }
val timeParts = value.split(":").mapNotNull { it.toIntOrNull() }
val curHour = (timeParts.getOrNull(0) ?: 8).coerceIn(0, 23)
val curMin = (timeParts.getOrNull(1) ?: 0).coerceIn(0, 59)
Text(field.label, style = MaterialTheme.typography.labelLarge)
TimeDisplayCard(hour = curHour, minute = curMin, onClick = { showTimePicker = true })
if (field.help.isNotBlank()) Text(field.help, style = MaterialTheme.typography.bodySmall)
if (showTimePicker) {
val timePickerState =
rememberTimePickerState(
initialHour = curHour,
initialMinute = curMin,
is24Hour = true,
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.fillMaxWidth().padding(8.dp),
confirmButton = {
TextButton(
onClick = {
onValueChange(String.format(java.util.Locale.ROOT, "%02d:%02d", timePickerState.hour, timePickerState.minute))
showTimePicker = false
},
) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(R.string.action_cancel))
}
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TimePicker(state = timePickerState)
}
},
)
}
}
else -> {
@@ -936,3 +998,528 @@ private fun DeliverField(
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SchedulePickerSection(
schedule: String,
onScheduleChange: (String) -> Unit,
) {
val parsed = remember(schedule) { parseSchedule(schedule) }
var mode by remember(schedule) { mutableStateOf(parsed.mode) }
var hour by remember { mutableStateOf(parsed.hour) }
var minute by remember { mutableStateOf(parsed.minute) }
var intervalCount by remember(schedule) { mutableStateOf(parsed.intervalCount) }
var intervalUnit by remember(schedule) { mutableStateOf(parsed.intervalUnit) }
var selectedDays by remember(schedule) { mutableStateOf(parsed.selectedDays) }
var dayOfMonth by remember(schedule) { mutableStateOf(parsed.dayOfMonth) }
var month by remember(schedule) { mutableStateOf(parsed.month) }
var isAdvanced by remember { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) }
var repeatExpanded by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
fun emitSchedule(
newMode: ScheduleMode,
newHour: Int = hour,
newMinute: Int = minute,
newIntervalCount: Int = intervalCount,
newIntervalUnit: String = intervalUnit,
newDays: Set<Int> = selectedDays,
newDom: Int = dayOfMonth,
newMonth: Int = month,
) {
onScheduleChange(ParsedSchedule(
mode = newMode, hour = newHour, minute = newMinute,
intervalCount = newIntervalCount, intervalUnit = newIntervalUnit,
selectedDays = newDays, dayOfMonth = newDom, month = newMonth, raw = schedule,
).toSchedule())
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.cron_edit_field_schedule),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
TextButton(
onClick = {
isAdvanced = !isAdvanced
if (isAdvanced) {
mode = ScheduleMode.ADVANCED
} else {
val fallback = parseSchedule(schedule)
mode = fallback.mode
hour = fallback.hour
minute = fallback.minute
}
},
) {
Icon(
imageVector = if (isAdvanced) Icons.Filled.Schedule else Icons.Filled.Code,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
if (isAdvanced) {
stringResource(R.string.cron_schedule_visual_toggle)
} else {
stringResource(R.string.cron_schedule_advanced_toggle)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
if (isAdvanced) {
OutlinedTextField(
value = schedule,
onValueChange = onScheduleChange,
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
textStyle =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
)
} else {
val repeatOptions = listOf(
ScheduleMode.DAILY to R.string.cron_schedule_mode_daily,
ScheduleMode.HOURLY to R.string.cron_repeat_hourly,
ScheduleMode.MINUTELY to R.string.cron_repeat_minutely,
ScheduleMode.WEEKLY to R.string.cron_schedule_mode_weekly,
ScheduleMode.MONTHLY to R.string.cron_schedule_mode_monthly,
ScheduleMode.YEARLY to R.string.cron_repeat_yearly,
ScheduleMode.INTERVAL to R.string.cron_schedule_mode_interval,
)
ExposedDropdownMenuBox(
expanded = repeatExpanded,
onExpandedChange = { repeatExpanded = it },
) {
OutlinedTextField(
value = stringResource(repeatOptions.firstOrNull { it.first == mode }?.second
?: R.string.cron_schedule_advanced_toggle),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.cron_repeat_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = repeatExpanded) },
modifier = Modifier.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = repeatExpanded, onDismissRequest = { repeatExpanded = false }) {
repeatOptions.forEach { (option, label) ->
DropdownMenuItem(
text = { Text(stringResource(label)) },
onClick = {
mode = option
repeatExpanded = false
emitSchedule(option)
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.cron_repeat_seconds_unavailable)) },
onClick = {},
enabled = false,
)
}
}
when (mode) {
ScheduleMode.DAILY -> {
TimeDisplayCard(
hour = hour,
minute = minute,
onClick = { showTimePicker = true },
)
}
ScheduleMode.WEEKLY -> {
TimeDisplayCard(
hour = hour,
minute = minute,
onClick = { showTimePicker = true },
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TextButton(
onClick = {
selectedDays = setOf(1, 2, 3, 4, 5)
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
},
) {
Text(stringResource(R.string.cron_schedule_weekdays_workdays), style = MaterialTheme.typography.labelSmall)
}
TextButton(
onClick = {
selectedDays = setOf(6, 0)
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
},
) {
Text(stringResource(R.string.cron_schedule_weekdays_weekend), style = MaterialTheme.typography.labelSmall)
}
TextButton(
onClick = {
selectedDays = setOf(1, 2, 3, 4, 5, 6, 0)
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
},
) {
Text(stringResource(R.string.cron_schedule_weekdays_all), style = MaterialTheme.typography.labelSmall)
}
}
val daysConfig =
listOf(
1 to R.string.cron_dow_mon,
2 to R.string.cron_dow_tue,
3 to R.string.cron_dow_wed,
4 to R.string.cron_dow_thu,
5 to R.string.cron_dow_fri,
6 to R.string.cron_dow_sat,
0 to R.string.cron_dow_sun,
)
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
daysConfig.forEach { (d, labelRes) ->
val isSelected = selectedDays.contains(d)
FilterChip(
selected = isSelected,
onClick = {
val nextDays =
if (isSelected) {
if (selectedDays.size > 1) selectedDays - d else selectedDays
} else {
selectedDays + d
}
selectedDays = nextDays
emitSchedule(ScheduleMode.WEEKLY, newDays = nextDays)
},
label = { Text(stringResource(labelRes)) },
)
}
}
}
}
ScheduleMode.INTERVAL -> {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_count),
options = ((1..120).toList() + intervalCount).distinct().sorted().map { it.toString() },
selectedValue = intervalCount.toString(),
onOptionSelected = {
intervalCount = it.toInt()
emitSchedule(mode, newIntervalCount = intervalCount)
},
)
val units = listOf("m", "h", "d")
val labels = listOf(stringResource(R.string.cron_unit_minute),
stringResource(R.string.cron_unit_hour), stringResource(R.string.cron_unit_day))
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_unit),
options = labels,
selectedValue = labels[units.indexOf(intervalUnit)],
onOptionSelected = {
intervalUnit = units[labels.indexOf(it)]
emitSchedule(mode, newIntervalUnit = intervalUnit)
},
)
Text(
text = stringResource(R.string.cron_schedule_interval_presets),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val presets =
listOf(
Triple(1, "m", "1 " + stringResource(R.string.cron_unit_minute)),
Triple(15, "m", "15 " + stringResource(R.string.cron_unit_minute)),
Triple(30, "m", "30 " + stringResource(R.string.cron_unit_minute)),
Triple(1, "h", "1 " + stringResource(R.string.cron_unit_hour)),
Triple(2, "h", "2 " + stringResource(R.string.cron_unit_hour)),
Triple(6, "h", "6 " + stringResource(R.string.cron_unit_hour)),
Triple(12, "h", "12 " + stringResource(R.string.cron_unit_hour)),
Triple(1, "d", "1 " + stringResource(R.string.cron_unit_day)),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
presets.forEach { (cnt, unt, title) ->
val isSelected = intervalCount == cnt && intervalUnit == unt
FilterChip(
selected = isSelected,
onClick = {
intervalCount = cnt
intervalUnit = unt
emitSchedule(ScheduleMode.INTERVAL, newIntervalCount = cnt, newIntervalUnit = unt)
},
label = { Text(title) },
)
}
}
}
}
ScheduleMode.MONTHLY, ScheduleMode.YEARLY -> {
if (mode == ScheduleMode.YEARLY) {
val months = java.text.DateFormatSymbols(LocalConfiguration.current.locales[0]).months.take(12)
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_month),
options = months,
selectedValue = months[month - 1],
onOptionSelected = {
month = months.indexOf(it) + 1
dayOfMonth = dayOfMonth.coerceAtMost(java.time.Month.of(month).maxLength())
emitSchedule(mode, newDom = dayOfMonth, newMonth = month)
},
)
}
TimeDisplayCard(
hour = hour,
minute = minute,
onClick = { showTimePicker = true },
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.cron_schedule_day_of_month_label, dayOfMonth),
style = MaterialTheme.typography.bodyMedium,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(
onClick = {
if (dayOfMonth > 1) {
dayOfMonth--
emitSchedule(mode, newDom = dayOfMonth)
}
},
enabled = dayOfMonth > 1,
) {
Text("-", style = MaterialTheme.typography.titleLarge)
}
Text(
text = dayOfMonth.toString(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
IconButton(
onClick = {
val maxDay = if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31
if (dayOfMonth < maxDay) {
dayOfMonth++
emitSchedule(mode, newDom = dayOfMonth)
}
},
enabled = dayOfMonth < (if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31),
) {
Text("+", style = MaterialTheme.typography.titleLarge)
}
}
}
}
ScheduleMode.HOURLY -> {
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_minute),
options = (0..59).map { it.toString().padStart(2, '0') },
selectedValue = minute.toString().padStart(2, '0'),
onOptionSelected = { minute = it.toInt(); emitSchedule(mode, newMinute = minute) },
)
}
ScheduleMode.MINUTELY, ScheduleMode.ADVANCED -> Unit
}
if ((mode == ScheduleMode.MONTHLY && dayOfMonth > 28) ||
(mode == ScheduleMode.YEARLY && month == 2 && dayOfMonth == 29)) {
Text(stringResource(R.string.cron_repeat_calendar_note), style = MaterialTheme.typography.bodySmall)
}
}
// Live Summary Card
Text(
stringResource(R.string.cron_schedule_timezone_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = scheduleSummary(parsed, schedule),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
}
}
}
if (showTimePicker) {
val timePickerState =
rememberTimePickerState(
initialHour = hour,
initialMinute = minute,
is24Hour = true,
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.fillMaxWidth().padding(8.dp),
confirmButton = {
TextButton(
onClick = {
hour = timePickerState.hour
minute = timePickerState.minute
showTimePicker = false
emitSchedule(mode, newHour = timePickerState.hour, newMinute = timePickerState.minute)
},
) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(R.string.action_cancel))
}
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TimePicker(state = timePickerState)
}
},
)
}
}
@Composable
private fun TimeDisplayCard(
hour: Int,
minute: Int,
onClick: () -> Unit,
) {
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = stringResource(R.string.cron_schedule_select_time),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = String.format(java.util.Locale.ROOT, "%02d:%02d", hour, minute),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
}
}
Text(
text = stringResource(R.string.cron_action_edit),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun scheduleSummary(parsed: ParsedSchedule, raw: String): String {
val time = java.lang.String.format(java.util.Locale.ROOT, "%02d:%02d", parsed.hour, parsed.minute)
return when (parsed.mode) {
ScheduleMode.DAILY -> stringResource(R.string.cron_summary_daily, time)
ScheduleMode.HOURLY -> stringResource(R.string.cron_summary_hourly, parsed.minute)
ScheduleMode.MINUTELY -> stringResource(R.string.cron_repeat_minutely)
ScheduleMode.WEEKLY -> {
val days = listOf(R.string.cron_dow_sun, R.string.cron_dow_mon, R.string.cron_dow_tue,
R.string.cron_dow_wed, R.string.cron_dow_thu, R.string.cron_dow_fri, R.string.cron_dow_sat)
val names = parsed.selectedDays.sorted().map { stringResource(days[it]) }.joinToString(", ")
stringResource(R.string.cron_summary_weekly, names, time)
}
ScheduleMode.MONTHLY -> stringResource(R.string.cron_summary_monthly, parsed.dayOfMonth, time)
ScheduleMode.YEARLY -> stringResource(R.string.cron_summary_yearly, parsed.dayOfMonth, parsed.month, time)
ScheduleMode.INTERVAL -> {
val unit = stringResource(when (parsed.intervalUnit) {
"m" -> R.string.cron_unit_minute
"h" -> R.string.cron_unit_hour
else -> R.string.cron_unit_day
})
stringResource(R.string.cron_summary_interval, parsed.intervalCount, unit)
}
ScheduleMode.ADVANCED -> raw
}
}
@@ -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,
@@ -0,0 +1,152 @@
package com.m57.hermescontrol.ui.cron
internal enum class ScheduleMode {
HOURLY,
MINUTELY,
YEARLY,
DAILY,
WEEKLY,
INTERVAL,
MONTHLY,
ADVANCED,
}
internal data class ParsedSchedule(
val mode: ScheduleMode,
val hour: Int = 9,
val minute: Int = 0,
val intervalCount: Int = 1,
val intervalUnit: String = "h",
val selectedDays: Set<Int> = setOf(1),
val dayOfMonth: Int = 1,
val month: Int = 1,
val raw: String = "",
)
internal fun parseSchedule(schedule: String): ParsedSchedule {
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
if (clean.isBlank()) {
return ParsedSchedule(mode = ScheduleMode.DAILY, hour = 9, minute = 0)
}
val everyMatch = Regex("""^every\s+(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(clean)
if (everyMatch != null) {
val count = everyMatch.groupValues[1].toIntOrNull()
?: return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
if (count <= 0) return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
val unit = everyMatch.groupValues[2].lowercase()
return ParsedSchedule(
mode = ScheduleMode.INTERVAL,
intervalCount = count,
intervalUnit = unit,
)
}
val minStepMatch = Regex("""^\*/(\d+)\s+\*\s+\*\s+\*\s+\*$""").find(clean)
if (minStepMatch != null) {
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
}
val hourStepMatch = Regex("""^0\s+\*/(\d+)\s+\*\s+\*\s+\*$""").find(clean)
if (hourStepMatch != null) {
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
}
val parts = clean.split(" ")
if (clean == "* * * * *") return ParsedSchedule(mode = ScheduleMode.MINUTELY)
if (parts.size == 5 && parts.drop(1).all { it == "*" }) {
val minute = parts[0].toIntOrNull()
if (minute != null && minute in 0..59) return ParsedSchedule(mode = ScheduleMode.HOURLY, minute = minute)
}
if (parts.size == 5) {
val minute = parts[0].toIntOrNull()
val hour = parts[1].toIntOrNull()
val dom = parts[2]
val month = parts[3]
val dow = parts[4]
if (minute != null && hour != null && minute in 0..59 && hour in 0..23) {
val monthNumber = month.toIntOrNull()
val dayNumber = dom.toIntOrNull()
if (monthNumber != null && monthNumber in 1..12 && dayNumber != null &&
dayNumber in 1..java.time.Month.of(monthNumber).maxLength() && dow == "*") {
return ParsedSchedule(ScheduleMode.YEARLY, hour = hour, minute = minute,
dayOfMonth = dayNumber, month = monthNumber)
}
if (dom == "*" && month == "*" && dow == "*") {
return ParsedSchedule(
mode = ScheduleMode.DAILY,
hour = hour,
minute = minute,
)
}
if (dom == "*" && month == "*" && dow != "*") {
val days = parseDowList(dow)
if (days.isNotEmpty()) {
return ParsedSchedule(
mode = ScheduleMode.WEEKLY,
hour = hour,
minute = minute,
selectedDays = days.toSet(),
)
}
}
if (dom != "*" && month == "*" && dow == "*") {
val domInt = dom.toIntOrNull()
if (domInt != null && domInt in 1..31) {
return ParsedSchedule(
mode = ScheduleMode.MONTHLY,
hour = hour,
minute = minute,
dayOfMonth = domInt,
)
}
}
}
}
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
}
internal fun ParsedSchedule.toSchedule(): String {
require(hour in 0..23 && minute in 0..59)
require(dayOfMonth in 1..31 && month in 1..12)
return when (mode) {
ScheduleMode.DAILY -> "$minute $hour * * *"
ScheduleMode.HOURLY -> "$minute * * * *"
ScheduleMode.MINUTELY -> "* * * * *"
ScheduleMode.WEEKLY -> {
require(selectedDays.isNotEmpty() && selectedDays.all { it in 0..6 })
"$minute $hour * * ${selectedDays.sorted().joinToString(",")}"
}
ScheduleMode.MONTHLY -> "$minute $hour $dayOfMonth * *"
ScheduleMode.YEARLY -> {
require(dayOfMonth <= java.time.Month.of(month).maxLength())
"$minute $hour $dayOfMonth $month *"
}
ScheduleMode.INTERVAL -> {
require(intervalCount > 0 && intervalUnit in listOf("m", "h", "d"))
"every $intervalCount$intervalUnit"
}
ScheduleMode.ADVANCED -> raw
}
}
private fun parseDowList(dow: String): List<Int> {
if (dow.contains("-")) {
val bounds = dow.split("-")
if (bounds.size == 2) {
val start = bounds[0].toIntOrNull() ?: return emptyList()
val end = bounds[1].toIntOrNull() ?: return emptyList()
if (start in 0..7 && end in 0..7 && start <= end) {
return (start..end).map { if (it == 7) 0 else it }.distinct().sorted()
}
}
}
val values = dow.split(",").map { it.trim().toIntOrNull() ?: return emptyList() }
if (values.any { it !in 0..7 }) return emptyList()
return values
.map { if (it == 7) 0 else it }
.distinct()
.sorted()
}
@@ -46,6 +46,7 @@ import com.m57.hermescontrol.SettingsConnection
import com.m57.hermescontrol.SettingsLanguage
import com.m57.hermescontrol.SettingsVault
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.settings.components.SupportSection
@@ -74,7 +75,7 @@ fun SettingsScreen(
) {
SettingsCategoryCard(
items =
listOf(
listOfNotNull(
SettingsRow(
icon = Icons.AutoMirrored.Filled.ListAlt,
label = stringResource(R.string.settings_sec_connection),
@@ -94,7 +95,7 @@ fun SettingsScreen(
label = stringResource(R.string.screen_account_connectors),
summary = stringResource(R.string.account_connectors_summary),
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
),
).takeIf { AiturkFeatureGate.shows(AccountConnectorsScreen) },
SettingsRow(
icon = Icons.Filled.Palette,
label = stringResource(R.string.settings_sec_appearance),
@@ -136,7 +137,7 @@ fun SettingsScreen(
label = stringResource(R.string.settings_sec_vault),
summary = stringResource(R.string.settings_summary_vault),
onClick = { NavigationController.navigateTo(SettingsVault) },
),
).takeIf { AiturkFeatureGate.shows(SettingsVault) },
SettingsRow(
icon = Icons.Filled.Info,
label = stringResource(R.string.settings_sec_about),
@@ -24,6 +24,22 @@ object CronExpressionFormatter {
fun cronToHumanReadable(schedule: String): String {
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
if (clean.startsWith("every ", ignoreCase = true)) {
val rest = clean.substring(6).trim()
val match = Regex("""^(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(rest)
if (match != null) {
val num = match.groupValues[1].toIntOrNull() ?: 1
val unit = match.groupValues[2].lowercase()
val unitStr = when (unit) {
"m" -> if (num == 1) "minute" else "minutes"
"h" -> if (num == 1) "hour" else "hours"
"d" -> if (num == 1) "day" else "days"
else -> unit
}
return "Every " + num + " " + unitStr
}
return schedule
}
val parts = clean.split(" ")
if (parts.size != 5) {
return schedule
@@ -134,9 +150,13 @@ object CronExpressionFormatter {
}
val dows = parseField(dowPart, 0, 7)
if (dows.size == 1) {
val dowName = dowNames[dows[0]]
return "Every $dowName at $timeStr"
if (dows.isNotEmpty()) {
val names = dows.map { dowNames[it] }.distinct()
return if (names.size == 1) {
"Every " + names[0] + " at " + timeStr
} else {
"Every " + names.joinToString(", ") + " at " + timeStr
}
}
return null
+66 -9
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 -->
@@ -1252,8 +1253,8 @@
<string name="cron_action_run">Çalıştır</string>
<string name="cron_action_edit">Düzenle</string>
<string name="cron_action_add">Zamanlanmış görev ekle</string>
<string name="cron_edit_title">Zamanlanmış görevi düzenle</string>
<string name="cron_edit_title_new">Yeni zamanlanmış görev</string>
<string name="cron_edit_title">Görevi düzenle</string>
<string name="cron_edit_title_new">Yeni görev</string>
<string name="cron_edit_start_from">Başlangıç şablonu</string>
<string name="cron_edit_blank_job">Boş görev</string>
<string name="cron_edit_field_name">Ad</string>
@@ -1287,11 +1288,34 @@
<string name="cron_edit_hint_skills">Her satıra bir tane</string>
<string name="cron_edit_action_save">Kaydet</string>
<string name="cron_discard_title">Değişiklikler atılsın mı?</string>
<string name="cron_discard_message">Kaydedilmemiş değişiklikler var. Atılsın mı?</string>
<string name="cron_delete_title">Görev silinsin mi?</string>
<string name="cron_delete_message">\"%1$s\" silinsin mi? Bu işlem geri alınamaz.</string>
<string name="cron_discard_message">Kaydedilmemiş değişiklikler var. Atılsın mı?</string>
<string name="cron_delete_title">Görev silinsin mi?</string>
<string name="cron_delete_message">\"%1$s\" silinsin mi? Bu işlem geri alınamaz.</string>
<string name="cron_schedule_mode_interval">Aralık</string>
<string name="cron_schedule_mode_daily">Her gün</string>
<string name="cron_schedule_mode_weekly">Her hafta</string>
<string name="cron_schedule_mode_monthly">Her ay</string>
<string name="cron_schedule_mode_advanced">Gelişmiş</string>
<string name="cron_schedule_select_time">Çalışma Saati</string>
<string name="cron_schedule_advanced_toggle">Cron ifadesini elle düzenle</string>
<string name="cron_schedule_visual_toggle">Görsel zamanlayıcıya dön</string>
<string name="cron_schedule_weekdays_workdays">Hafta içi</string>
<string name="cron_schedule_weekdays_weekend">Hafta sonu</string>
<string name="cron_schedule_weekdays_all">Her gün</string>
<string name="cron_schedule_interval_presets">Hızlı aralıklar</string>
<string name="cron_schedule_day_of_month_label">Ayın %1$d. günü</string>
<string name="cron_dow_mon">Pzt</string>
<string name="cron_dow_tue">Sal</string>
<string name="cron_dow_wed">Çar</string>
<string name="cron_dow_thu">Per</string>
<string name="cron_dow_fri">Cum</string>
<string name="cron_dow_sat">Cmt</string>
<string name="cron_dow_sun">Paz</string>
<string name="cron_unit_minute">dk</string>
<string name="cron_unit_hour">saat</string>
<string name="cron_unit_day">gün</string>
<!-- Achievements Screen -->
<!-- Achievements Screen -->
<string name="achievements_empty_title">Henüz başarım yok</string>
<string name="achievements_empty_desc">Başarım kazanmak için görevleri tamamlayın.</string>
<string name="achievements_label_progress">İlerleme: %1$d / %2$d</string>
@@ -1866,7 +1890,7 @@
<!-- AITURK IDE pairing (QR / code) -->
<string name="landing_action_aiturk_pair">Bilgisayara bağlan (QR / kod)</string>
<string name="aiturk_pair_title">Bilgisayara bağlan</string>
<string name="aiturk_pair_desc">Bilgisayarınızda AITURK IDE → Ayarlar → Telefon bağlantısı ekranını açın. Orada görünen QR kodu okutun ya da 8 karakterli kodu yazın. Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.</string>
<string name="aiturk_pair_desc">Bilgisayarınızda AITURK IDE → Ayarlar → Telefon bağlantısı ekranını açın. Orada görünen QR kodu okutun ya da 8 karakterli kodu yazın. İnternetin olduğu her yerden çalışır; aynı Wi-Fi gerekmez. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.</string>
<string name="aiturk_pair_scan_qr">QR okut</string>
<string name="aiturk_pair_scan_prompt">Kamerayı AITURK IDE\'deki QR koda tutun</string>
<string name="aiturk_pair_or">ya da</string>
@@ -1878,9 +1902,9 @@
<string name="aiturk_pair_progress_resolve">Kod aranıyor…</string>
<string name="aiturk_pair_progress_connect">Bilgisayara bağlanılıyor…</string>
<string name="aiturk_pair_confirm_title">Bu bilgisayara bağlanılsın mı?</string>
<string name="aiturk_pair_confirm_message">Bir bağlantı, AITURK Mobil\'in ağınızdaki %1$s adresli bilgisayara bağlanmasını istiyor. Yalnızca bu bağlantıyı AITURK IDE\'den kendiniz açtıysanız devam edin.</string>
<string name="aiturk_pair_confirm_message">Bir bağlantı, AITURK Mobil\'in %1$s adresli bilgisayara bağlanmasını istiyor. Yalnızca bu bağlantıyı AITURK IDE\'den kendiniz açtıysanız devam edin.</string>
<string name="aiturk_pair_error_camera_denied">Kamera izni verilmedi. Bunun yerine 8 karakterli kodu yazabilirsiniz.</string>
<string name="aiturk_pair_error_lan_permission">Yerel ağdaki cihazlara erişim izni verilmedi. Bilgisayarınıza bağlanmak için bu izni verin.</string>
<string name="aiturk_pair_error_lan_permission">Yerel ağdaki cihazlara erişim izni verilmediği için bilgisayara Wi-Fi üzerinden ulaşılamadı. Telefonun ayarlarından bu izni verip tekrar deneyin.</string>
<string name="aiturk_pair_error_qr_not_aiturk">Bu bir AITURK eşleştirme QR kodu değil.</string>
<string name="aiturk_pair_error_update_app">Bu QR kod için AITURK Mobil\'in daha yeni bir sürümü gerekiyor. Lütfen uygulamayı güncelleyin.</string>
<string name="aiturk_pair_error_qr_invalid">QR kod eksik ya da bozuk. AITURK IDE\'de Telefon bağlantısı ekranını yeniden açıp tekrar deneyin.</string>
@@ -1894,4 +1918,37 @@
<string name="aiturk_pair_error_pairing_locked">Çok fazla yanlış kod girildi, eşleştirme iptal edildi. AITURK IDE\'de Telefon bağlantısını yeniden başlatın.</string>
<string name="aiturk_pair_error_unexpected">Bilgisayar beklenmeyen bir cevap verdi. AITURK IDE ve AITURK Mobil\'i güncelleyip tekrar deneyin.</string>
<string name="aiturk_pair_error_save_failed">Eşleşti ama bağlantı kaydedilemedi. Lütfen tekrar deneyin.</string>
<string name="aiturk_pair_error_host_offline">Bilgisayarda AITURK IDE açık değil ya da Telefon bağlantısı kapalı. AITURK IDE\'yi açıp Telefon bağlantısını etkinleştirin ve tekrar deneyin.</string>
<string name="aiturk_pair_error_host_timeout">Bilgisayar cevap vermedi. AITURK IDE\'nin çalıştığını kontrol edip tekrar deneyin.</string>
<string name="aiturk_pair_error_relay_busy">Çok fazla bağlantı, biraz sonra yeniden deneyin.</string>
<string name="aiturk_pair_error_relay_unknown">Bu eşleşme artık geçerli değil, AITURK IDE\'den yeniden eşleştirin.</string>
<string name="aiturk_pair_error_tls_pin">Bilgisayarın kimliği doğrulanamadı, AITURK IDE\'den yeniden eşleştirin.</string>
<string name="aiturk_pair_security_title">Bağlandı</string>
<string name="aiturk_pair_security_message">Güvenlik kodu: %1$s\n\nAITURK IDE\'de QR kodun altında görünen güvenlik koduyla aynı olmalı. Bağlantı uçtan uca şifrelidir.</string>
<string name="aiturk_pair_security_ok">Tamam</string>
<string name="aiturk_pair_error_no_internet">İnternete ulaşılamadı. Telefonun Wi-Fi ya da mobil veri bağlantısını kontrol edip tekrar deneyin.</string>
<string name="aiturk_pair_error_relay">Bağlantı hatası. Biraz sonra tekrar deneyin; sürerse AITURK IDE ve AITURK Mobil\'i güncelleyin.</string>
<string name="aiturk_pair_error_code">Tanı kodu: %1$s</string>
<string name="aiturk_status_host_offline">Bilgisayarda AITURK IDE açık değil ya da Telefon bağlantısı kapalı</string>
<string name="aiturk_feature_unavailable">Bu özellik AITURK IDE\'nin bu sürümünde yok.</string>
<string name="aiturk_feature_unavailable_hint">Bilgisayardaki AITURK IDE\'den kullanın ya da AITURK IDE ve AITURK Mobil\'i güncelleyin.</string>
<string name="aiturk_phone_forbidden">Güvenlik nedeniyle bu işlem telefondan yapılamaz. Bilgisayardaki AITURK IDE\'den yapın.</string>
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
<string name="cron_repeat_label">Tekrar</string>
<string name="cron_repeat_hourly">Her saat</string>
<string name="cron_repeat_minutely">Her dakika</string>
<string name="cron_repeat_yearly">Her yıl</string>
<string name="cron_repeat_seconds_unavailable">Her saniye · sunucu desteklemiyor</string>
<string name="cron_repeat_month">Ay</string>
<string name="cron_repeat_minute">Saatin kaçıncı dakikasında?</string>
<string name="cron_summary_daily">Her gün %1$s</string>
<string name="cron_summary_hourly">Her saat, %1$02d. dakikada</string>
<string name="cron_summary_weekly">%1$s · %2$s</string>
<string name="cron_summary_monthly">Her ayın %1$d. günü · %2$s</string>
<string name="cron_summary_yearly">Her yıl %1$d/%2$d · %3$s</string>
<string name="cron_summary_interval">Her %1$d %2$s</string>
<string name="cron_repeat_calendar_note">Seçilen günün olmadığı ay veya yıllarda çalışmaz.</string>
<string name="cron_repeat_count">Aralık</string>
<string name="cron_repeat_unit">Birim</string>
<string name="cron_schedule_timezone_note">Saatler bağlı AITURK sunucusunun saat dilimine göredir. Sunucunun dilimi burada okunamıyor; telefonunuzdan farklı olabilir.</string>
</resources>
+64 -7
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 -->
@@ -1287,11 +1288,34 @@
<string name="cron_edit_hint_skills">One per line</string>
<string name="cron_edit_action_save">Save</string>
<string name="cron_discard_title">Discard changes?</string>
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
<string name="cron_delete_title">Delete job?</string>
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
<string name="cron_delete_title">Delete job?</string>
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
<string name="cron_schedule_mode_interval">Interval</string>
<string name="cron_schedule_mode_daily">Daily</string>
<string name="cron_schedule_mode_weekly">Weekly</string>
<string name="cron_schedule_mode_monthly">Monthly</string>
<string name="cron_schedule_mode_advanced">Advanced</string>
<string name="cron_schedule_select_time">Execution Time</string>
<string name="cron_schedule_advanced_toggle">Edit raw cron expression</string>
<string name="cron_schedule_visual_toggle">Switch to visual scheduler</string>
<string name="cron_schedule_weekdays_workdays">Weekdays</string>
<string name="cron_schedule_weekdays_weekend">Weekend</string>
<string name="cron_schedule_weekdays_all">Every day</string>
<string name="cron_schedule_interval_presets">Quick intervals</string>
<string name="cron_schedule_day_of_month_label">Day of month: %1$d</string>
<string name="cron_dow_mon">Mon</string>
<string name="cron_dow_tue">Tue</string>
<string name="cron_dow_wed">Wed</string>
<string name="cron_dow_thu">Thu</string>
<string name="cron_dow_fri">Fri</string>
<string name="cron_dow_sat">Sat</string>
<string name="cron_dow_sun">Sun</string>
<string name="cron_unit_minute">min</string>
<string name="cron_unit_hour">hr</string>
<string name="cron_unit_day">day</string>
<!-- Achievements Screen -->
<!-- Achievements Screen -->
<string name="achievements_empty_title">No achievements yet</string>
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
<string name="achievements_label_progress">Progress: %1$d / %2$d</string>
@@ -1866,7 +1890,7 @@
<!-- AITURK IDE pairing (QR / code) -->
<string name="landing_action_aiturk_pair">Connect to computer (QR / code)</string>
<string name="aiturk_pair_title">Connect to your computer</string>
<string name="aiturk_pair_desc">On your computer open AITURK IDE → Settings → Phone connection. Scan the QR code shown there or type the 8-character code. The phone and the computer must be on the same Wi-Fi.</string>
<string name="aiturk_pair_desc">On your computer open AITURK IDE → Settings → Phone connection. Scan the QR code shown there or type the 8-character code. It works from anywhere with internet; the same Wi-Fi is not required. The computer must be on with AITURK IDE running.</string>
<string name="aiturk_pair_scan_qr">Scan QR</string>
<string name="aiturk_pair_scan_prompt">Point the camera at the QR code in AITURK IDE</string>
<string name="aiturk_pair_or">or</string>
@@ -1878,9 +1902,9 @@
<string name="aiturk_pair_progress_resolve">Looking up the code…</string>
<string name="aiturk_pair_progress_connect">Connecting to your computer…</string>
<string name="aiturk_pair_confirm_title">Connect to this computer?</string>
<string name="aiturk_pair_confirm_message">A link is asking AITURK Mobil to connect to the computer at %1$s on your network. Continue only if you opened this link yourself from AITURK IDE.</string>
<string name="aiturk_pair_confirm_message">A link is asking AITURK Mobil to connect to the computer at %1$s. Continue only if you opened this link yourself from AITURK IDE.</string>
<string name="aiturk_pair_error_camera_denied">Camera permission was not granted. You can type the 8-character code instead.</string>
<string name="aiturk_pair_error_lan_permission">Access to devices on your local network was not allowed. Allow it to connect to your computer.</string>
<string name="aiturk_pair_error_lan_permission">Access to devices on your local network was not allowed, so the computer could not be reached over Wi-Fi. Allow the permission in the phone\'s settings and try again.</string>
<string name="aiturk_pair_error_qr_not_aiturk">This is not an AITURK pairing QR code.</string>
<string name="aiturk_pair_error_update_app">This QR code needs a newer AITURK Mobil. Please update the app.</string>
<string name="aiturk_pair_error_qr_invalid">The QR code is incomplete or damaged. Open Phone connection in AITURK IDE again and retry.</string>
@@ -1894,4 +1918,37 @@
<string name="aiturk_pair_error_pairing_locked">Too many wrong codes, pairing was cancelled. Start Phone connection again in AITURK IDE.</string>
<string name="aiturk_pair_error_unexpected">The computer gave an unexpected answer. Update AITURK IDE and AITURK Mobil, then try again.</string>
<string name="aiturk_pair_error_save_failed">Paired, but the connection could not be saved. Please try again.</string>
<string name="aiturk_pair_error_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off. Open AITURK IDE, turn on Phone connection and try again.</string>
<string name="aiturk_pair_error_host_timeout">The computer did not answer. Check that AITURK IDE is running and try again.</string>
<string name="aiturk_pair_error_relay_busy">Too many connections. Try again in a moment.</string>
<string name="aiturk_pair_error_relay_unknown">This pairing is no longer valid. Pair again from AITURK IDE.</string>
<string name="aiturk_pair_error_tls_pin">The computer\'s identity could not be verified. Pair again from AITURK IDE.</string>
<string name="aiturk_pair_security_title">Connected</string>
<string name="aiturk_pair_security_message">Security code: %1$s\n\nIt should match the security code shown under the QR code in AITURK IDE. The connection is end-to-end encrypted.</string>
<string name="aiturk_pair_security_ok">OK</string>
<string name="aiturk_pair_error_no_internet">The internet could not be reached. Check the phone\'s Wi-Fi or mobile data and try again.</string>
<string name="aiturk_pair_error_relay">Connection error. Try again in a moment; if it continues, update AITURK IDE and AITURK Mobil.</string>
<string name="aiturk_pair_error_code">Diagnostic code: %1$s</string>
<string name="aiturk_status_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off</string>
<string name="aiturk_feature_unavailable">This feature is not available in this version of AITURK IDE.</string>
<string name="aiturk_feature_unavailable_hint">Use it in AITURK IDE on your computer, or update AITURK IDE and AITURK Mobil.</string>
<string name="aiturk_phone_forbidden">For security this cannot be done from the phone. Do it in AITURK IDE on your computer.</string>
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
<string name="cron_repeat_label">Repeat</string>
<string name="cron_repeat_hourly">Every hour</string>
<string name="cron_repeat_minutely">Every minute</string>
<string name="cron_repeat_yearly">Every year</string>
<string name="cron_repeat_seconds_unavailable">Every second · unsupported by server</string>
<string name="cron_repeat_month">Month</string>
<string name="cron_repeat_minute">At which minute of the hour?</string>
<string name="cron_summary_daily">Every day at %1$s</string>
<string name="cron_summary_hourly">Every hour at minute %1$02d</string>
<string name="cron_summary_weekly">%1$s · %2$s</string>
<string name="cron_summary_monthly">Day %1$d of each month · %2$s</string>
<string name="cron_summary_yearly">Every year on %1$d/%2$d · %3$s</string>
<string name="cron_summary_interval">Every %1$d %2$s</string>
<string name="cron_repeat_calendar_note">Skips months or years without the selected date.</string>
<string name="cron_repeat_count">Interval</string>
<string name="cron_repeat_unit">Unit</string>
<string name="cron_schedule_timezone_note">Times use the connected AITURK server’s time zone. Its zone cannot be read here and may differ from your phone’s.</string>
</resources>
@@ -0,0 +1,325 @@
package com.m57.hermescontrol.data.aiturk
import com.m57.hermescontrol.AccountConnectorsScreen
import com.m57.hermescontrol.ChatScreen
import com.m57.hermescontrol.FilesScreen
import com.m57.hermescontrol.KeysScreen
import com.m57.hermescontrol.R
import com.m57.hermescontrol.SettingsVault
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.mapHttpError
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.ReplayTracker
import com.m57.hermescontrol.data.ws.RpcChannel
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ChatWsEventReducer
import com.m57.hermescontrol.ui.chat.StreamingState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* A paired AITURK IDE does not have every method and route this app knows.
* The user must never read `JsonRpcError(code=-32601, …)` or `unknown method`,
* a missing method must not be asked for again and again, and none of this may
* change how a plain Hermes dashboard connection behaves.
*/
@OptIn(ExperimentalCoroutinesApi::class)
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() {
AiturkCompat.resetForTest()
AiturkCompat.stringLookup = { id ->
when (id) {
R.string.aiturk_feature_unavailable -> unavailable
R.string.aiturk_phone_forbidden -> forbidden
R.string.aiturk_backend_not_ready -> backendNotReady
else -> null
}
}
}
@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,
) {
val sent = mutableListOf<Pair<String, String>>()
val events = mutableListOf<WsEvent>()
val channel =
RpcChannel(
scope = scope,
json = Json { ignoreUnknownKeys = true },
sendRequest = { method, _, onSent ->
val id = "req-${sent.size + 1}"
sent.add(id to method)
onSent?.invoke(id)
id
},
sendFrame = { true },
removeQueuedMessage = {},
onIdleCheck = {},
)
val replay =
ReplayTracker(
scope = scope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { events.add(it) },
)
fun answerUnknownMethod(
id: String,
method: String,
) = channel.handleIncomingFrame(
"""{"jsonrpc":"2.0","id":"$id","error":{"code":-32601,"message":"unknown method: $method"}}""",
replay,
) { events.add(it) }
}
private fun failure(deferred: CompletableDeferred<Any?>): HermesWsClient.HermesRpcException =
deferred.getCompletionExceptionOrNull() as HermesWsClient.HermesRpcException
// ── JSON-RPC ─────────────────────────────────────────────────────────
@Test
fun unknownMethodOnPairedIdeFailsQuietlyWithAPlainSentence() =
runTest {
AiturkCompat.linkedOverride = true
val gateway = Gateway(this)
val call = gateway.channel.request("connectors.list")
gateway.answerUnknownMethod("req-1", "connectors.list")
// No RpcError event: the chat reducer would have turned it into a banner.
assertTrue(gateway.events.isEmpty(), "events: ${gateway.events}")
val error = failure(call)
assertEquals(-32601, error.code)
assertEquals(unavailable, error.message)
assertFalse(error.message.orEmpty().contains("unknown method"))
}
@Test
fun aMissingMethodIsNotSentAgainUntilTheNextSocket() =
runTest {
AiturkCompat.linkedOverride = true
val gateway = Gateway(this)
gateway.channel.request("connectors.list")
gateway.answerUnknownMethod("req-1", "connectors.list")
val again = gateway.channel.request("connectors.list")
assertEquals(listOf("req-1" to "connectors.list"), gateway.sent)
assertEquals(-32601, failure(again).code)
assertEquals(unavailable, failure(again).message)
assertTrue(gateway.events.isEmpty())
// Another method is unaffected, and a new socket (maybe a newer IDE) asks again.
gateway.channel.request("session.list")
AiturkRelayStatus.onSocketOpened()
gateway.channel.request("connectors.list")
assertEquals(listOf("connectors.list", "session.list", "connectors.list"), gateway.sent.map { it.second })
}
@Test
fun plainHermesConnectionKeepsTheUpstreamBehaviour() =
runTest {
AiturkCompat.linkedOverride = false
val gateway = Gateway(this)
val call = gateway.channel.request("connectors.list")
gateway.answerUnknownMethod("req-1", "connectors.list")
assertEquals(1, gateway.events.size)
assertTrue(gateway.events.single() is WsEvent.RpcError)
assertEquals("unknown method: connectors.list", failure(call).message)
gateway.channel.request("connectors.list")
assertEquals(2, gateway.sent.size)
}
@Test
fun otherRpcErrorsOnPairedIdeStillReachTheirConsumers() =
runTest {
AiturkCompat.linkedOverride = true
val gateway = Gateway(this)
val call = gateway.channel.request("session.resume")
gateway.channel.handleIncomingFrame(
"""{"jsonrpc":"2.0","id":"req-1","error":{"code":4001,"message":"session not found"}}""",
gateway.replay,
) { gateway.events.add(it) }
assertEquals(1, gateway.events.size)
assertEquals("session not found", failure(call).message)
gateway.channel.request("session.resume")
assertEquals(2, gateway.sent.size)
}
@Test
fun fireAndForgetUnknownMethodStillEmitsItsEvent() =
runTest {
// ChatViewModel needs this event to fall back from session.branch_whole to session.branch.
AiturkCompat.linkedOverride = true
val gateway = Gateway(this)
gateway.answerUnknownMethod("77", "session.branch_whole")
assertEquals(1, gateway.events.size)
assertEquals(-32601, (gateway.events.single() as WsEvent.RpcError).error.code)
}
// ── Banner text ──────────────────────────────────────────────────────
@Test
fun chatBannerNeverShowsTheRawErrorOnPairedIde() {
AiturkCompat.linkedOverride = true
val error = JsonRpcError(-32601, "unknown method: connectors.list")
val reduced =
ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), WsEvent.RpcError("9", error), null)
assertEquals(unavailable, reduced.state.errorMessage)
val other = JsonRpcError(4001, "session not found")
assertEquals("session not found", AiturkCompat.rpcBannerText(other, legacy = "Error: $other"))
assertEquals(unavailable, AiturkCompat.rpcBannerText(mapOf("code" to -32601), legacy = "x"))
}
@Test
fun chatBannerIsUnchangedForPlainHermes() {
AiturkCompat.linkedOverride = false
val error = JsonRpcError(-32601, "unknown method: connectors.list")
val reduced =
ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), WsEvent.RpcError("9", error), null)
assertEquals("Error: $error", reduced.state.errorMessage)
}
// ── REST ─────────────────────────────────────────────────────────────
@Test
fun proxyRefusalAndMissingRouteGetPlainSentences() {
AiturkCompat.linkedOverride = true
assertEquals(forbidden, mapHttpError(403, """{"error":"forbidden"}""").message)
assertEquals(unavailable, mapHttpError(404, """{"detail":"Not Found"}""").message)
assertEquals(unavailable, mapHttpError(404, """{"error":"not_found"}""").message)
assertEquals(403, (mapHttpError(403, """{"error":"forbidden"}""") as NetworkError.Http).code)
}
@Test
fun realRecordErrorsAndPlainHermesKeepTheirOwnText() {
AiturkCompat.linkedOverride = true
assertEquals("Session not found", mapHttpError(404, """{"detail":"Session not found"}""").message)
assertNull(AiturkCompat.httpErrorMessage(403, """{"detail":"Profile is read-only"}"""))
assertNull(AiturkCompat.httpErrorMessage(500, """{"error":"forbidden"}"""))
assertNull(AiturkCompat.httpErrorMessage(403, "not json"))
AiturkCompat.linkedOverride = false
assertNull(AiturkCompat.httpErrorMessage(403, """{"error":"forbidden"}"""))
assertTrue(mapHttpError(403, """{"error":"forbidden"}""").message.startsWith("Forbidden (HTTP 403)"))
}
@Test
fun unsavedChatSkipsTheSessionDetailPollOnlyOnPairedIde() {
AiturkCompat.linkedOverride = true
assertTrue(AiturkCompat.skipSessionDetail(transcriptEmpty = true))
assertFalse(AiturkCompat.skipSessionDetail(transcriptEmpty = false))
AiturkCompat.linkedOverride = false
assertFalse(AiturkCompat.skipSessionDetail(transcriptEmpty = true))
}
// ── Network and lifecycle ────────────────────────────────────────────
@Test
fun returningToTheForegroundReopensAnIdleClosedSocket() {
AiturkCompat.linkedOverride = true
var connects = 0
AiturkCompat.onAppForeground(ConnectionStatus.DISCONNECTED, autoReconnect = { true }) { connects++ }
assertEquals(1, connects)
// Open, opening, signed out or switched off by the user: left alone.
for (status in ConnectionStatus.entries - ConnectionStatus.DISCONNECTED) {
AiturkCompat.onAppForeground(status, autoReconnect = { true }) { connects++ }
}
AiturkCompat.onAppForeground(ConnectionStatus.DISCONNECTED, autoReconnect = { false }) { connects++ }
AiturkCompat.linkedOverride = false
AiturkCompat.onAppForeground(ConnectionStatus.DISCONNECTED, autoReconnect = { true }) { connects++ }
assertEquals(1, connects)
}
@Test
fun networkChangeDropsIdleTunnelConnectionsOnlyOnPairedIde() {
var evictions = 0
AiturkCompat.linkedOverride = true
AiturkCompat.onNetworkChanged { evictions++ }
assertEquals(1, evictions)
AiturkCompat.linkedOverride = false
AiturkCompat.onNetworkChanged { evictions++ }
assertEquals(1, evictions)
}
@Test
fun aNewSocketClearsTheIdeClosedNote() {
AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))
assertTrue(AiturkRelayStatus.hostOffline.value)
AiturkRelayStatus.onSocketOpened()
assertFalse(AiturkRelayStatus.hostOffline.value)
}
// ── Screens ──────────────────────────────────────────────────────────
@Test
fun screensThePairedIdeCannotServeAreHidden() {
for (key in listOf(AccountConnectorsScreen, KeysScreen, FilesScreen, SettingsVault)) {
assertFalse(AiturkFeatureGate.shows(key, linked = true), "$key")
assertTrue(AiturkFeatureGate.shows(key, linked = false), "$key")
}
assertTrue(AiturkFeatureGate.shows(ChatScreen, linked = true))
}
@Test
fun versionLabelShowsNameAndBuildNumber() {
assertEquals("v1.4.0 (37)", AiturkCompat.versionLabel("1.4.0", 37))
}
}
@@ -0,0 +1,148 @@
package com.m57.hermescontrol.data.aiturk
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okio.ByteString
import okio.ByteString.Companion.toByteString
import java.net.InetAddress
import java.net.Socket
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.concurrent.thread
/**
* A local stand-in for the turkservis.online tunnel relay: a WebSocket endpoint
* (`/t/<relayId>`) that bridges binary frames to a TCP socket of [targetPort] —
* exactly what gateway + IDE relay client do together. It never looks inside
* the bytes, so the TLS handshake with the fake IDE really runs end to end.
*/
internal class FakeRelay(
private val targetPort: () -> Int,
) {
val server = MockWebServer()
val streams = AtomicInteger()
val bytesFromPhone = AtomicLong()
val paths = CopyOnWriteArrayList<String>()
val headerNames = CopyOnWriteArrayList<String>()
val textFrames = AtomicInteger()
/** When set, every new stream is accepted and immediately closed with this code. */
@Volatile
var closeWith: Int? = null
private val sockets = CopyOnWriteArrayList<Socket>()
private val webSockets = CopyOnWriteArrayList<WebSocket>()
/** Cuts every open stream abnormally, like a network change (Wi-Fi ⇄ mobile data). */
fun dropStreams() {
webSockets.forEach { runCatching { it.cancel() } }
sockets.forEach { runCatching { it.close() } }
}
val wsBase: String get() = "ws://$LOOPBACK:${server.port}/t/"
fun connector(): AiturkTunnelConnector = WebSocketTunnelConnector(wsBase, OkHttpClient())
fun start(): FakeRelay {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
paths += request.path.orEmpty()
headerNames += request.headers.names().map { it.lowercase() }
return MockResponse().withWebSocketUpgrade(Bridge())
}
}
server.start(InetAddress.getByName(LOOPBACK), 0)
return this
}
fun stop() {
sockets.forEach { runCatching { it.close() } }
runCatching { server.shutdown() }
}
private inner class Bridge : WebSocketListener() {
@Volatile
private var tcp: Socket? = null
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
streams.incrementAndGet()
webSockets += webSocket
closeWith?.let {
webSocket.close(it, null)
return
}
val socket = Socket(LOOPBACK, targetPort())
sockets += socket
tcp = socket
thread(isDaemon = true, name = "fake-relay-pump") {
val buffer = ByteArray(BUFFER)
try {
val input = socket.getInputStream()
while (true) {
val read = input.read(buffer)
if (read < 0) break
webSocket.send(buffer.toByteString(0, read))
}
webSocket.close(NORMAL, null)
} catch (_: Exception) {
webSocket.close(NORMAL, null)
}
}
}
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) {
bytesFromPhone.addAndGet(bytes.size.toLong())
val socket = tcp ?: return
runCatching {
socket.getOutputStream().write(bytes.toByteArray())
socket.getOutputStream().flush()
}
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
textFrames.incrementAndGet()
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
webSocket.close(code, null)
runCatching { tcp?.close() }
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
runCatching { tcp?.close() }
}
}
companion object {
const val LOOPBACK = "127.0.0.1"
const val RELAY_ID = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f"
const val OTHER_RELAY_ID = "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d"
private const val BUFFER = 16 * 1024
private const val NORMAL = 1000
}
}
@@ -16,14 +16,111 @@ class AiturkPairingPayloadTest {
us: List<String> = listOf("192.168.1.20:47321"),
fingerprint: String? = fp,
code: String? = "ABCDEFGH",
relays: List<String> = emptyList(),
): String =
buildList {
v?.let { add("v=$it") }
us.forEach { add("u=$it") }
fingerprint?.let { add("fp=$it") }
code?.let { add("c=$it") }
relays.forEach { add("r=$it") }
}.joinToString("&", prefix = "aiturk://pair?")
private val relayId = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f"
// ── Contract v3: relay id ────────────────────────────────────────────
@Test
fun relayOnlyPayloadIsTheDefaultAndNeedsNoAddress() {
// Exactly what the IDE prints by default: aiturk://pair?v=1&fp=…&c=…&r=<relayId>
val result = AiturkPairing.parse("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId")
val offer = (result as AiturkPayloadResult.Ok).payload.offer
assertEquals(relayId, offer.relay)
assertTrue(offer.endpoints.isEmpty())
assertEquals(fp, offer.fingerprint)
assertEquals("https://$relayId.relay.aiturk.invalid/", AiturkRelay.baseUrl(relayId))
assertEquals("$relayId.relay.aiturk.invalid:443", AiturkRelay.hostPort(relayId))
assertEquals(relayId, AiturkRelay.relayIdOfHost("$relayId.relay.aiturk.invalid"))
}
@Test
fun relayAndLanAddressesCanComeTogether() {
val result = AiturkPairing.parse(link(us = listOf("192.168.1.20:47321", "10.0.0.5:47322"), relays = listOf(relayId)))
val offer = (result as AiturkPayloadResult.Ok).payload.offer
assertEquals(relayId, offer.relay)
assertEquals(2, offer.endpoints.size)
// Without `r` the offer simply has no relay.
assertNull((AiturkPairing.parse(link()) as AiturkPayloadResult.Ok).payload.offer.relay)
}
@Test
fun anythingButALowercaseUuid4RelayIdRejectsThePayload() {
val rejected =
listOf(
// 1. the cancelled v2 form (a URL)
"https%3A%2F%2Fturkservis.online%2Fv1%2Faiturk%2Fmobile-relay%2Fr%2F$relayId",
// 2. upper case
relayId.uppercase(Locale.ROOT),
// 3. not version 4
"3f2b8c1e-5d4a-1b7c-9e1f-0a1b2c3d4e5f",
// 4. wrong variant nibble
"3f2b8c1e-5d4a-4b7c-ce1f-0a1b2c3d4e5f",
// 5. a host name smuggled in
"$relayId.evil.example",
// 6. extra path
"$relayId%2F..%2Fhost",
// 7. surrounding whitespace
"%20$relayId",
// 8. braces / no dashes
"%7B$relayId%7D",
relayId.replace("-", ""),
// 9. too short / empty-ish
relayId.dropLast(1),
"x",
)
rejected.forEach { value ->
// The LAN address is valid: a bad `r` must still reject the whole payload.
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(relays = listOf(value))), value)
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(us = emptyList(), relays = listOf(value))), value)
assertNull(AiturkRelay.parse(java.net.URLDecoder.decode(value, "UTF-8")), value)
}
// 10. `r` given twice
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(relays = listOf(relayId, relayId))))
// An empty `r=` is a parameter without a value.
assertEquals(AiturkPayloadError.MALFORMED, errorOf(link() + "&r"))
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link() + "&r="))
}
@Test
fun payloadWithNeitherAddressNorRelayIsRejected() {
assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = emptyList())))
val five = (1..5).map { "192.168.1.$it:47321" }
assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = five, relays = listOf(relayId))))
// A relay does not excuse a bad LAN address.
assertEquals(AiturkPayloadError.BAD_ADDRESS, errorOf(link(us = listOf("8.8.8.8:443"), relays = listOf(relayId))))
// The fingerprint stays mandatory: it is what makes the tunnel end-to-end.
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf("aiturk://pair?v=1&c=ABCDEFGH&r=$relayId"))
}
@Test
fun onlyExactVirtualHostsAreRelayHosts() {
assertTrue(AiturkRelay.isVirtualHost("$relayId.relay.aiturk.invalid"))
listOf(
null,
"",
"relay.aiturk.invalid",
".relay.aiturk.invalid",
"abc.relay.aiturk.invalid",
"${relayId.uppercase(Locale.ROOT)}.relay.aiturk.invalid",
"$relayId.relay.aiturk.invalid.evil.example",
"x.$relayId.relay.aiturk.invalid",
"turkservis.online",
"192.168.1.20",
).forEach { assertFalse(AiturkRelay.isVirtualHost(it), it.toString()) }
}
private fun errorOf(raw: String): AiturkPayloadError? = (AiturkPairing.parse(raw) as? AiturkPayloadResult.Invalid)?.error
@Test
@@ -0,0 +1,393 @@
package com.m57.hermescontrol.data.aiturk
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.ServerSocket
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.cert.CertificateException
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException
/** Contract v3 pairing order: tunnel first, LAN second, exactly one `claim`. */
class AiturkRelayPairingTest {
private lateinit var gateway: MockWebServer
private lateinit var relayIde: FakeIde
private lateinit var relay: FakeRelay
private val others = mutableListOf<FakeIde>()
@BeforeEach
fun setUp() {
gateway = MockWebServer()
gateway.start()
relayIde = FakeIde().start()
relay = FakeRelay { relayIde.server.port }.start()
}
@AfterEach
fun tearDown() {
relay.stop()
runCatching { gateway.shutdown() }
(others + relayIde).forEach { runCatching { it.stop() } }
}
private fun client(
helloTimeoutMs: Long = 2_500L,
relayHelloTimeoutMs: Long = 5_000L,
) = AiturkPairingClient(
gatewayBaseUrl = gateway.url("/"),
helloTimeoutMs = helloTimeoutMs,
relayHelloTimeoutMs = relayHelloTimeoutMs,
tunnel = { relay.connector() },
)
private fun closedPort(): Int = ServerSocket(0).use { it.localPort }
private fun <T> AiturkPairResult<T>.failure(): AiturkPairResult.Failure = this as AiturkPairResult.Failure
// ── Order ────────────────────────────────────────────────────────────
@Test
fun relayIsTriedFirstAndLanCandidatesAreNeverContacted() =
runBlocking {
// Same certificate on the LAN listener, as with the real IDE.
val lan = FakeIde(relayIde.certificate).start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID)
val device = (client().pair(offer, "ABCDEFGH", "Galaxy S24") as AiturkPairResult.Success).value
assertEquals("https://${FakeRelay.RELAY_ID}.relay.aiturk.invalid/", device.baseUrl)
assertEquals("${FakeRelay.RELAY_ID}.relay.aiturk.invalid:443", device.hostPort)
assertNull(device.endpoint)
assertTrue(device.viaRelay)
assertEquals(relayIde.fingerprint, device.fingerprint)
assertEquals(FakeIde.TOKEN, device.token)
assertEquals(listOf("/aiturk/pair/hello", "/aiturk/pair/claim"), relayIde.paths.toList())
assertEquals("""{"code":"ABCDEFGH","device_name":"Galaxy S24"}""", relayIde.claimBodies.single())
assertTrue(lan.paths.isEmpty(), "LAN must not be contacted when the relay answered: ${lan.paths}")
}
@Test
fun relayOnlyOfferPairsWithoutAnyLanAddress() =
runBlocking {
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
// lanAllowed = false: the local-network permission is irrelevant for the tunnel.
val result = client().pair(offer, "ABCDEFGH", "x", lanAllowed = false)
assertTrue(result is AiturkPairResult.Success)
assertEquals(1, relayIde.claimCount)
}
@Test
fun hostOfflineOnTheRelayFallsBackToTheLan() =
runBlocking {
relay.closeWith = 4503
val lan = FakeIde().start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), lan.fingerprint, FakeRelay.RELAY_ID)
val device = (client().pair(offer, "ABCDEFGH", "x") as AiturkPairResult.Success).value
assertEquals(lan.endpoint, device.endpoint)
assertEquals(lan.endpoint.baseUrl, device.baseUrl)
assertEquals(lan.endpoint.hostPort, device.hostPort)
assertFalse(device.viaRelay)
assertEquals(1, lan.claimCount)
assertEquals(0, relayIde.claimCount)
assertEquals(1, relay.streams.get())
}
@Test
fun hostOfflineThenUnreachableLanKeepsBothCodes() =
runBlocking {
relay.closeWith = 4503
val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort())
val offer = AiturkPairingOffer(listOf(dead), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.HOST_OFFLINE, failure.error)
assertEquals("relay:host_offline,lan:refused", failure.code)
assertEquals(0, relayIde.claimCount)
}
@Test
fun relayFailuresAreToldApart() =
runBlocking {
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
val cases =
listOf(
Triple(4503, AiturkPairError.HOST_OFFLINE, "relay:host_offline,lan:no_candidates"),
Triple(4404, AiturkPairError.RELAY_UNKNOWN, "relay:unknown,lan:no_candidates"),
Triple(4429, AiturkPairError.RELAY_BUSY, "relay:busy,lan:no_candidates"),
Triple(4504, AiturkPairError.HOST_TIMEOUT, "relay:timeout,lan:no_candidates"),
)
cases.forEach { (closeCode, error, code) ->
relay.closeWith = closeCode
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(error, failure.error, "close $closeCode")
assertEquals(code, failure.code, "close $closeCode")
}
relay.stop()
val offline = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.NO_INTERNET, offline.error)
assertEquals("relay:network,lan:no_candidates", offline.code)
assertEquals(0, relayIde.claimCount)
}
@Test
fun wrongCertificateInsideTheTunnelIsAPinFailure() =
runBlocking {
val offer = AiturkPairingOffer(emptyList(), FakeIde.fingerprintOf(FakeIde.selfSigned()), FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.TLS_PIN, failure.error)
assertEquals("tls:pin,lan:no_candidates", failure.code)
assertTrue(relayIde.paths.isEmpty(), "nothing may be sent to an unverified computer: ${relayIde.paths}")
}
@Test
fun silentComputerBehindAnOpenTunnelTimesOut() =
runBlocking {
relayIde.hello = FakeIde.json(200, "{}").setHeadersDelay(5, TimeUnit.SECONDS)
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client(relayHelloTimeoutMs = 600L).pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.HOST_TIMEOUT, failure.error)
assertEquals("relay:timeout,lan:no_candidates", failure.code)
}
// ── One claim ────────────────────────────────────────────────────────
@Test
fun claimAnsweredOverTheRelayIsNeverRepeatedOnTheLan() =
runBlocking {
relayIde.claim = FakeIde.json(401, """{"error":"wrong_code"}""")
val lan = FakeIde(relayIde.certificate).start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.WRONG_CODE, failure.error)
assertEquals("claim:wrong_code", failure.code)
assertEquals(1, relayIde.claimCount)
assertTrue(lan.paths.isEmpty(), "a failed claim must not be retried on the LAN: ${lan.paths}")
}
@Test
fun claimCutMidFlightIsNotSentAgain() =
runBlocking {
// The computer receives the claim; the tunnel drops before its answer returns.
relayIde.claim = FakeIde.json(200, FakeIde.claimBody()).setHeadersDelay(5, TimeUnit.SECONDS)
val lan = FakeIde(relayIde.certificate).start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID)
Thread {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10)
while (relayIde.claimCount == 0 && System.nanoTime() < deadline) Thread.sleep(10)
relay.dropStreams()
}.apply { isDaemon = true }.start()
val result = client().pair(offer, "ABCDEFGH", "x")
assertTrue(result is AiturkPairResult.Failure)
assertTrue(result.failure().code.startsWith("claim:relay_"), result.failure().code)
assertEquals(1, relayIde.claimCount, "the single-use code must be sent exactly once")
assertEquals(0, lan.claimCount)
}
@Test
fun pairingClosedOverTheRelaySpendsNoClaim() =
runBlocking {
relayIde.hello = FakeIde.json(200, """{"app":"aiturk-ide","version":"1","pairing":false}""")
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.PAIRING_CLOSED, failure.error)
assertEquals("relay:pairing_closed", failure.code)
assertEquals(0, relayIde.claimCount)
}
// ── LAN codes ────────────────────────────────────────────────────────
@Test
fun lanFailuresCarryTheirOwnCodes() =
runBlocking {
val refused = AiturkPairingOffer(listOf(AiturkEndpoint(FakeIde.LOOPBACK, closedPort())), relayIde.fingerprint)
val refusedFailure = client().pair(refused, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.PC_UNREACHABLE, refusedFailure.error)
assertEquals("lan:refused", refusedFailure.code)
val impostor = FakeIde().start().also { others += it }
val tls = AiturkPairingOffer(listOf(impostor.endpoint), relayIde.fingerprint)
val tlsFailure = client().pair(tls, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.PC_UNREACHABLE, tlsFailure.error)
assertEquals("lan:tls", tlsFailure.code)
val silent =
FakeIde().apply { hello = FakeIde.json(200, "{}").setHeadersDelay(5, TimeUnit.SECONDS) }.start().also { others += it }
val timeout = AiturkPairingOffer(listOf(silent.endpoint), silent.fingerprint)
val timeoutFailure = client(helloTimeoutMs = 400L).pair(timeout, "ABCDEFGH", "x").failure()
assertEquals("lan:timeout", timeoutFailure.code)
val denied = client().pair(refused, "ABCDEFGH", "x", lanAllowed = false).failure()
assertEquals(AiturkPairError.LAN_PERMISSION, denied.error)
assertEquals("lan:permission", denied.code)
assertEquals(0, relay.streams.get(), "an offer without a relay id must never open a tunnel")
}
@Test
fun everyExceptionTypeHasItsOwnKind() {
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(SocketTimeoutException("x")))
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(InterruptedIOException("timeout")))
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(ConnectException("connect failed: ETIMEDOUT (Connection timed out)")))
assertEquals(AiturkDiag.REFUSED, AiturkDiag.networkKind(ConnectException("connect failed: ECONNREFUSED (Connection refused)")))
assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(NoRouteToHostException("x")))
assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(ConnectException("connect failed: EHOSTUNREACH (No route to host)")))
assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(ConnectException("connect failed: ENETUNREACH (Network is unreachable)")))
assertEquals(AiturkDiag.PERMISSION, AiturkDiag.networkKind(ConnectException("connect failed: EPERM (Operation not permitted)")))
assertEquals(AiturkDiag.PERMISSION, AiturkDiag.networkKind(java.net.SocketException("socket failed: EACCES (Permission denied)")))
assertEquals(AiturkDiag.DNS, AiturkDiag.networkKind(UnknownHostException("host")))
assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(SSLHandshakeException("x")))
assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(SSLException("x")))
assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(IOException("wrapped", CertificateException("pin"))))
assertEquals(AiturkDiag.IO, AiturkDiag.networkKind(IOException("unexpected end of stream")))
assertEquals(AiturkPairError.LAN_PERMISSION, AiturkPairingClient.lanError(AiturkDiag.PERMISSION))
listOf(AiturkDiag.TIMEOUT, AiturkDiag.REFUSED, AiturkDiag.UNREACHABLE, AiturkDiag.TLS, AiturkDiag.IO).forEach {
assertEquals(AiturkPairError.PC_UNREACHABLE, AiturkPairingClient.lanError(it), it)
}
assertEquals(AiturkDiag.TLS, AiturkPairingClient.mostTelling(listOf(AiturkDiag.TIMEOUT, AiturkDiag.TLS, AiturkDiag.REFUSED)))
assertEquals(AiturkDiag.REFUSED, AiturkPairingClient.mostTelling(listOf(AiturkDiag.TIMEOUT, AiturkDiag.REFUSED)))
}
@Test
fun tunnelFailuresHaveTheirOwnKindsAndTexts() {
assertEquals(AiturkTunnelException.HOST_OFFLINE, AiturkDiag.tunnelKind(SSLException("x", AiturkTunnelException("host_offline", 4503))))
assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(SSLHandshakeException("x").apply { initCause(CertificateException("pin")) }))
assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(SSLPeerUnverifiedException("x")))
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.tunnelKind(SocketTimeoutException("x")))
assertEquals(AiturkDiag.TLS, AiturkDiag.tunnelKind(SSLException("closed")))
assertEquals(AiturkDiag.NETWORK, AiturkDiag.tunnelKind(IOException("x")))
assertEquals("tls:pin", AiturkDiag.relayCode(AiturkDiag.PIN))
assertEquals("relay:host_offline", AiturkDiag.relayCode(AiturkTunnelException.HOST_OFFLINE))
assertEquals(AiturkPairError.HOST_OFFLINE, AiturkPairingClient.relayError("host_offline"))
assertEquals(AiturkPairError.RELAY_UNKNOWN, AiturkPairingClient.relayError("unknown"))
assertEquals(AiturkPairError.NO_INTERNET, AiturkPairingClient.relayError("network"))
assertEquals(AiturkPairError.RELAY_BUSY, AiturkPairingClient.relayError("busy"))
assertEquals(AiturkPairError.HOST_TIMEOUT, AiturkPairingClient.relayError("timeout"))
assertEquals(AiturkPairError.TLS_PIN, AiturkPairingClient.relayError("pin"))
assertEquals(AiturkPairError.RELAY_ERROR, AiturkPairingClient.relayError("overflow"))
assertEquals(AiturkPairError.RELAY_ERROR, AiturkPairingClient.relayError("http_500"))
}
@Test
fun diagnosticCodeNeverCarriesATokenACodeOrAnAddress() =
runBlocking {
relay.closeWith = 4503
val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort())
val offer = AiturkPairingOffer(listOf(dead), relayIde.fingerprint, FakeRelay.RELAY_ID)
val code = client().pair(offer, "ABCDEFGH", "x").failure().code
listOf("ABCDEFGH", FakeRelay.RELAY_ID, FakeIde.LOOPBACK, dead.port.toString(), relayIde.fingerprint, "amd_", "https", ".invalid").forEach {
assertFalse(it in code, "'$it' leaked into '$code'")
}
assertEquals(code, AiturkDiag.sanitize(code))
// The exception message (which on Android names the address) is never copied.
val noisy = ConnectException("failed to connect to /192.168.1.20 (port 47321): connect failed: ECONNREFUSED")
assertEquals("refused", AiturkDiag.networkKind(noisy))
// The UI drops anything that is not built from the fixed vocabulary.
listOf(
"relay:" + FakeIde.TOKEN,
"lan:192.168.1.20:47321",
"claim:ABCDEFGH",
"relay:https://turkservis.online/x",
"relay:host_offline lan:timeout",
"x".repeat(200) + ":y",
).forEach { assertEquals("", AiturkDiag.sanitize(it), it) }
assertEquals("relay:host_offline,lan:timeout", AiturkDiag.sanitize("relay:host_offline,lan:timeout"))
assertEquals("tls:pin", AiturkDiag.sanitize("tls:pin"))
assertEquals("resolve:http_500", AiturkDiag.sanitize("resolve:http_500"))
}
// ── resolve ──────────────────────────────────────────────────────────
@Test
fun resolveAcceptsARelayIdWithOrWithoutLanAddresses() =
runBlocking {
val fp = "A".repeat(43)
gateway.enqueue(FakeIde.json(200, """{"urls":[],"fp":"$fp","relay":"${FakeRelay.RELAY_ID}","expiresIn":280}"""))
gateway.enqueue(FakeIde.json(200, """{"fp":"$fp","relay":"${FakeRelay.RELAY_ID}"}"""))
gateway.enqueue(FakeIde.json(200, """{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":"${FakeRelay.RELAY_ID}"}"""))
gateway.enqueue(FakeIde.json(200, """{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":null}"""))
val relayOnly = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value
assertEquals(AiturkPairingOffer(emptyList(), fp, FakeRelay.RELAY_ID), relayOnly)
assertEquals(relayOnly, (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value)
val both = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value
assertEquals(AiturkPairingOffer(listOf(AiturkEndpoint("192.168.1.20", 47321)), fp, FakeRelay.RELAY_ID), both)
val lanOnly = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value
assertNull(lanOnly.relay)
}
@Test
fun resolveRejectsAnythingThatIsNotARelayId() =
runBlocking {
val fp = "A".repeat(43)
val id = FakeRelay.RELAY_ID
listOf(
"""{"urls":[],"fp":"$fp","relay":"https://turkservis.online/v1/aiturk/mobile-relay/r/$id"}""",
"""{"urls":[],"fp":"$fp","relay":"${id.uppercase()}"}""",
"""{"urls":[],"fp":"$fp","relay":"$id.evil.example"}""",
"""{"urls":[],"fp":"$fp","relay":"$id/"}""",
"""{"urls":[],"fp":"$fp","relay":""}""",
"""{"urls":[],"fp":"$fp","relay":42}""",
"""{"urls":[],"fp":"$fp","relay":["$id"]}""",
// A wrong relay is never silently dropped in favour of the LAN addresses.
"""{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":"evil.example"}""",
"""{"urls":[],"fp":"$fp"}""",
"""{"urls":"https://192.168.1.20:47321","fp":"$fp","relay":"$id"}""",
).forEach { body ->
gateway.enqueue(FakeIde.json(200, body))
val failure = client().resolve("ABCDEFGH").failure()
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, failure.error, body)
assertEquals("resolve:bad_response", failure.code, body)
}
}
@Test
fun resolveFailuresHaveCodes() =
runBlocking {
gateway.enqueue(MockResponse().setResponseCode(404))
gateway.enqueue(MockResponse().setResponseCode(429))
gateway.enqueue(MockResponse().setResponseCode(500))
assertEquals("resolve:not_found", client().resolve("ABCDEFGH").failure().code)
assertEquals("resolve:rate_limited", client().resolve("ABCDEFGH").failure().code)
assertEquals("resolve:http_500", client().resolve("ABCDEFGH").failure().code)
gateway.shutdown()
assertEquals("resolve:network", client().resolve("ABCDEFGH").failure().code)
}
}
@@ -0,0 +1,57 @@
package com.m57.hermescontrol.data.aiturk
import android.util.Log
import com.m57.hermescontrol.data.ws.HermesWsClient
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import okhttp3.WebSocket
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* The chat socket opening again (IDE restarted, network back) must clear the
* "AITURK IDE is closed" note and forget which methods the previous IDE
* process lacked. This drives the real socket listener, not the helper.
*/
class AiturkSocketOpenHookTest {
@BeforeEach
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
AiturkCompat.resetForTest()
HermesWsClient.disconnect(clearPendingMessages = true)
}
@AfterEach
fun tearDown() {
HermesWsClient.disconnect(clearPendingMessages = true)
AiturkCompat.resetForTest()
AiturkRelayStatus.onSocketEnded(null)
unmockkAll()
}
@Test
fun openingTheChatSocketClearsIdeClosedNoteAndMissingMethods() {
AiturkCompat.linkedOverride = true
AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))
AiturkCompat.noteRpcError("connectors.list", AiturkCompat.METHOD_NOT_FOUND)
assertTrue(AiturkRelayStatus.hostOffline.value)
assertTrue(AiturkCompat.isKnownMissing("connectors.list"))
val socket = mockk<WebSocket>(relaxed = true)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionGenerationForTest.set(7)
HermesWsClient.createListenerForTest(7).onOpen(socket, mockk(relaxed = true))
assertTrue(HermesWsClient.isConnected)
assertFalse(AiturkRelayStatus.hostOffline.value)
assertFalse(AiturkCompat.isKnownMissing("connectors.list"))
}
}
@@ -25,6 +25,10 @@ internal class FakeIde(
@Volatile
var claim: MockResponse = json(200, claimBody())
/** Answers for paths other than hello/claim (null → 404). */
@Volatile
var extra: (RecordedRequest) -> MockResponse? = { null }
val endpoint: AiturkEndpoint get() = AiturkEndpoint(LOOPBACK, server.port)
val fingerprint: String get() = fingerprintOf(certificate)
val claimCount: Int get() = paths.count { it == "/aiturk/pair/claim" }
@@ -49,7 +53,7 @@ internal class FakeIde(
}
else -> {
json(404, """{"error":"not_found"}""")
extra(request) ?: json(404, """{"error":"not_found"}""")
}
}
}
@@ -68,6 +68,33 @@ class AiturkTokenRefreshSkipTest {
verify(exactly = 0) { AuthManager.setToken(any()) }
}
@Test
fun tunnelPairedConnectionNeverAsksTheDashboardForAToken() =
runBlocking {
// Contract v3: a relay pairing is the virtual host, pinned on port 443.
val relayId = FakeRelay.RELAY_ID
AiturkLanTrust.pin(AiturkRelay.hostPort(relayId), "A".repeat(43))
every { AuthManager.baseUrl() } returns AiturkRelay.baseUrl(relayId)
every { OkHttpProvider.probe } returns countingClient()
assertNull(DashboardSessionTokenRefresher.refreshAsync())
assertNull(DashboardSessionTokenRefresher.refresh())
assertEquals(0, calls.get())
verify(exactly = 0) { AuthManager.setToken(any()) }
}
@Test
fun unpairedVirtualHostIsNotTreatedAsPaired() =
runBlocking {
every { AuthManager.baseUrl() } returns AiturkRelay.baseUrl(FakeRelay.OTHER_RELAY_ID)
every { OkHttpProvider.probe } returns countingClient()
assertNull(DashboardSessionTokenRefresher.refreshAsync())
assertEquals(1, calls.get())
}
@Test
fun unpairedConnectionStillRefreshesAsBefore() =
runBlocking {
@@ -0,0 +1,417 @@
package com.m57.hermescontrol.data.aiturk
import okhttp3.Dns
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.net.URI
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.random.Random
/**
* Contract v3: REST and WebSocket traffic to the virtual relay host really
* travels through the tunnel, with TLS terminated by the (fake) IDE and pinned
* to its certificate — and nothing changes for any other host.
*/
class AiturkTunnelTest {
private lateinit var ide: FakeIde
private lateinit var relay: FakeRelay
private val virtualBase = "https://${AiturkRelay.virtualHost(FakeRelay.RELAY_ID)}"
@BeforeEach
fun setUp() {
AiturkLanTrust.resetForTest(MemoryPins())
ide = FakeIde().start()
relay = FakeRelay { ide.server.port }.start()
}
@AfterEach
fun tearDown() {
relay.stop()
runCatching { ide.stop() }
AiturkLanTrust.resetForTest(MemoryPins())
}
private fun pinnedClient(fingerprint: String = ide.fingerprint): OkHttpClient =
AiturkLanTrust.pinnedClient(
OkHttpClient
.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build(),
fingerprint,
) { relay.connector() }
// ── REST ─────────────────────────────────────────────────────────────
@Test
fun restRequestReachesTheIdeThroughTheTunnelWithPinnedTls() {
pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute().use { response ->
assertEquals(200, response.code)
assertTrue(response.body.string().contains("aiturk-ide"))
assertNotNull(response.handshake, "TLS must be established inside the tunnel")
}
// The IDE saw the request with NO path prefix; the relay saw only its own stream path.
assertEquals(listOf("/aiturk/pair/hello"), ide.paths.toList())
assertEquals(listOf("/t/${FakeRelay.RELAY_ID}"), relay.paths.toList())
assertEquals(0, relay.textFrames.get(), "the stream is binary only")
assertFalse("origin" in relay.headerNames, "headers seen by the relay: ${relay.headerNames}")
assertFalse("cookie" in relay.headerNames)
assertFalse("authorization" in relay.headerNames)
}
@Test
fun largeBodiesSurviveTheTunnelInBothDirections() {
val payload = Random(7).nextBytes(1_500_000)
ide.extra = { request ->
if (request.path == "/api/echo") {
MockResponse().setResponseCode(200).setBody(okio.Buffer().write(request.body.readByteArray()))
} else {
null
}
}
val request =
Request
.Builder()
.url("$virtualBase/api/echo")
.post(payload.toRequestBody("application/octet-stream".toMediaType()))
.build()
val echoed = pinnedClient().newCall(request).execute().use { it.body.bytes() }
assertArrayEquals(payload, echoed)
}
@Test
fun tokenTravelsOnlyInsideTls() {
val token = "amd_" + "s".repeat(43)
val seen = AtomicReference<String>()
ide.extra = { request ->
seen.set(request.getHeader("Authorization"))
FakeIde.json(200, """{"ok":true}""")
}
val sniffed = LinkedBlockingQueue<ByteArray>()
val sniffing =
AiturkTunnelConnector { relayId, timeoutMs ->
val inner = relay.connector().open(relayId, timeoutMs)
object : AiturkTunnelChannel by inner {
override val output =
object : java.io.OutputStream() {
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
override fun write(
b: ByteArray,
off: Int,
len: Int,
) {
sniffed += b.copyOfRange(off, off + len)
inner.output.write(b, off, len)
}
}
}
}
val client = AiturkLanTrust.pinnedClient(OkHttpClient(), ide.fingerprint) { sniffing }
client
.newCall(
Request
.Builder()
.url("$virtualBase/api/status?marker=PLAINTEXT-MARKER")
.header("Authorization", "Bearer $token")
.build(),
).execute()
.close()
assertEquals("Bearer $token", seen.get())
val onTheWire = String(sniffed.flatMap { it.asIterable() }.toByteArray(), Charsets.ISO_8859_1)
assertTrue(onTheWire.isNotEmpty())
assertFalse(token in onTheWire, "the device token must never be readable by the relay")
assertFalse("PLAINTEXT-MARKER" in onTheWire, "paths and queries must never be readable by the relay")
assertFalse("/api/status" in onTheWire)
}
// ── WebSocket ────────────────────────────────────────────────────────
@Test
fun webSocketWorksThroughTheTunnel() {
ide.extra = { request ->
if (request.path?.startsWith("/api/ws") == true) {
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
webSocket.send("echo:$text")
}
},
)
} else {
null
}
}
val opened = CountDownLatch(1)
val messages = LinkedBlockingQueue<String>()
val failure = AtomicReference<Throwable>()
val socket =
pinnedClient().newWebSocket(
Request.Builder().url("$virtualBase/api/ws?token=t").build(),
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
messages += text
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
failure.set(t)
opened.countDown()
}
},
)
try {
assertTrue(opened.await(10, TimeUnit.SECONDS), "WebSocket did not open")
assertNull(failure.get(), "WebSocket failed: ${failure.get()}")
socket.send("hello")
assertEquals("echo:hello", messages.poll(10, TimeUnit.SECONDS))
// The app's WebSocket URL is seen by the IDE as written (no prefix).
assertTrue(ide.paths.any { it == "/api/ws?token=t" }, "paths: ${ide.paths}")
} finally {
socket.cancel()
}
}
// ── Trust ────────────────────────────────────────────────────────────
@Test
fun certificateThatDoesNotMatchThePairingFingerprintIsRejected() {
val other = FakeIde.fingerprintOf(FakeIde.selfSigned())
val error =
assertThrows(IOException::class.java) {
pinnedClient(other).newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute()
}
assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(error))
assertTrue(ide.paths.isEmpty(), "no request may be sent before the pin matched: ${ide.paths}")
}
@Test
fun sharedClientHookUsesTheStoredPinForTheVirtualHost() {
// What OkHttpProvider.base does: AiturkLanTrust.applyTo(builder).
val previous = AiturkTunnel.connector
AiturkTunnel.connector = relay.connector()
try {
val client = AiturkLanTrust.applyTo(OkHttpClient.Builder()).build()
val request = Request.Builder().url("$virtualBase/aiturk/pair/hello").build()
// Not paired yet: platform trust rejects the IDE's self-signed certificate.
assertThrows(IOException::class.java) { client.newCall(request).execute() }
assertTrue(ide.paths.isEmpty())
AiturkLanTrust.pin(AiturkRelay.hostPort(FakeRelay.RELAY_ID), ide.fingerprint)
assertTrue(AiturkLanTrust.isPairedUrl("$virtualBase/"))
client.newCall(request).execute().use { assertEquals(200, it.code) }
// A pin for one relay id says nothing about another.
val otherHost = AiturkRelay.virtualHost(FakeRelay.OTHER_RELAY_ID)
assertFalse(AiturkLanTrust.isPairedUrl("https://$otherHost/"))
assertThrows(IOException::class.java) {
client.newCall(Request.Builder().url("https://$otherHost/aiturk/pair/hello").build()).execute()
}
} finally {
AiturkTunnel.connector = previous
}
}
@Test
fun onlyAnIdeAddressCanBePinned() {
val fp = "A".repeat(43)
assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("turkservis.online:443", fp) }
assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("evil.relay.aiturk.invalid:443", fp) }
assertThrows(IllegalArgumentException::class.java) {
AiturkLanTrust.pin("${AiturkRelay.virtualHost(FakeRelay.RELAY_ID)}:8443", fp)
}
AiturkLanTrust.pin(AiturkRelay.hostPort(FakeRelay.RELAY_ID), fp)
AiturkLanTrust.pin("192.168.1.20:47321", fp)
}
// ── Everything else is untouched ─────────────────────────────────────
@Test
fun otherHostsKeepDefaultDnsSocketsAndProxy() {
val opens = AtomicInteger()
val counting =
AiturkTunnelConnector { relayId, timeoutMs ->
opens.incrementAndGet()
relay.connector().open(relayId, timeoutMs)
}
val plain = MockWebServer()
plain.enqueue(MockResponse().setBody("plain"))
plain.start()
try {
val client = AiturkTunnel.applyTo(OkHttpClient.Builder()) { counting }.build()
val body = client.newCall(Request.Builder().url(plain.url("/x")).build()).execute().use { it.body.string() }
assertEquals("plain", body)
assertEquals(0, opens.get(), "a normal host must never open a tunnel")
assertEquals(0, relay.streams.get())
// DNS: real names go to the system resolver, unchanged.
val recorded = mutableListOf<String>()
val delegate =
Dns { host ->
recorded += host
Dns.SYSTEM.lookup(host)
}
assertEquals(Dns.SYSTEM.lookup("localhost"), AiturkTunnelDns(delegate).lookup("localhost"))
assertEquals(listOf("localhost"), recorded)
// Socket: an ordinary TCP connection made by java.net.Socket itself.
val socket = AiturkTunnelSocketFactory { counting }.createSocket()
assertTrue(socket is Socket)
socket.use {
it.connect(InetSocketAddress(plain.hostName, plain.port), 2_000)
assertTrue(it.isConnected)
assertNull((it as AiturkTunnelSocket).channel)
assertEquals(plain.port, it.port)
}
assertEquals(0, opens.get())
} finally {
plain.shutdown()
}
}
@Test
fun virtualHostResolvesToAPlaceholderWithoutTouchingRealDns() {
val host = AiturkRelay.virtualHost(FakeRelay.RELAY_ID)
val delegate = Dns { throw AssertionError("system DNS must not be asked about $it") }
val address = AiturkTunnelDns(delegate).lookup(host).single()
assertEquals(host, address.hostName)
assertEquals("192.0.2.1", address.hostAddress)
assertEquals(listOf(Proxy.NO_PROXY), AiturkTunnelProxySelector().select(URI("https://$host/")))
// Look-alikes are ordinary names.
listOf("relay.aiturk.invalid", "x.relay.aiturk.invalid", "${FakeRelay.RELAY_ID}.relay.aiturk.invalid.evil.example").forEach {
assertFalse(AiturkRelay.isVirtualHost(it), it)
}
}
// ── Close codes ──────────────────────────────────────────────────────
@Test
fun relayCloseCodesBecomeNamedFailures() {
val expected =
mapOf(
4503 to AiturkTunnelException.HOST_OFFLINE,
4404 to AiturkTunnelException.UNKNOWN,
4429 to AiturkTunnelException.BUSY,
4504 to AiturkTunnelException.TIMEOUT,
4408 to AiturkTunnelException.OVERFLOW,
)
expected.forEach { (code, kind) ->
relay.closeWith = code
val error =
assertThrows(IOException::class.java) {
pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute()
}
assertEquals(kind, AiturkDiag.tunnelKind(error), "close code $code")
assertEquals(code, AiturkTunnelException.find(error)?.closeCode)
}
assertTrue(ide.paths.isEmpty())
assertNull(AiturkTunnelException.kindForCloseCode(1000))
}
@Test
fun unreachableRelayIsANetworkFailure() {
relay.stop()
val error =
assertThrows(IOException::class.java) {
pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute()
}
assertEquals(AiturkTunnelException.NETWORK, AiturkDiag.tunnelKind(error))
}
@Test
fun hostOfflineIsRememberedForTheChatBanner() {
AiturkRelayStatus.onSocketEnded(IOException("x", AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503)))
assertTrue(AiturkRelayStatus.hostOffline.value)
AiturkRelayStatus.onSocketEnded(IOException("timeout"))
assertFalse(AiturkRelayStatus.hostOffline.value)
AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))
AiturkRelayStatus.onSocketOpened()
assertFalse(AiturkRelayStatus.hostOffline.value)
}
// ── No replay ────────────────────────────────────────────────────────
@Test
fun postCutMidFlightIsNotSentAgain() {
val posts = AtomicInteger()
val arrived = CountDownLatch(1)
ide.extra = { request ->
if (request.method == "POST") posts.incrementAndGet()
arrived.countDown()
// The computer got the request; its answer never makes it back.
FakeIde.json(200, """{"ok":true}""").setHeadersDelay(5, TimeUnit.SECONDS)
}
Thread {
if (arrived.await(10, TimeUnit.SECONDS)) relay.dropStreams()
}.apply { isDaemon = true }.start()
// Same setting the pairing `claim` uses.
val client = pinnedClient().newBuilder().retryOnConnectionFailure(false).build()
val request =
Request
.Builder()
.url("$virtualBase/api/once")
.post("""{"n":1}""".toRequestBody("application/json".toMediaType()))
.build()
assertThrows(IOException::class.java) { client.newCall(request).execute().use { it.body.string() } }
assertEquals(1, posts.get(), "a POST that may have reached the computer must not be replayed")
assertEquals(1, relay.streams.get(), "no second tunnel stream may be opened for a retry")
}
}
@@ -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()
@@ -0,0 +1,152 @@
package com.m57.hermescontrol.ui.aiturkpair
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.aiturk.AiturkPairError
import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice
import com.m57.hermescontrol.data.aiturk.AiturkPairingClient
import com.m57.hermescontrol.data.aiturk.AiturkPayloadError
import com.m57.hermescontrol.data.aiturk.AiturkTunnelConnector
import com.m57.hermescontrol.data.aiturk.AiturkTunnelException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
/**
* Contract v3: the local-network permission is asked for only when a LAN
* address may be tried. The coroutine dispatcher is never advanced, so no
* network call runs: these tests look at the decision made synchronously.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AiturkPairViewModelTest {
private val fp = "A".repeat(43)
private val relayId = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f"
private val tunnelOpens = AtomicInteger()
@BeforeEach
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel(): AiturkPairViewModel {
val tunnel =
AiturkTunnelConnector { _, _ ->
tunnelOpens.incrementAndGet()
throw AiturkTunnelException(AiturkTunnelException.NETWORK)
}
return AiturkPairViewModel(
client = AiturkPairingClient(gatewayBaseUrl = "http://127.0.0.1:9/".toHttpUrl(), tunnel = { tunnel }),
register = { _: AiturkPairedDevice -> },
deviceName = "Test phone",
)
}
@Test
fun relayOnlyQrNeverAsksForTheLocalNetworkPermission() {
val vm = viewModel()
vm.onQrScanned("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId", lanPermissionGranted = false)
val state = vm.uiState.value
assertFalse(state.lanPermissionNeeded, "a relay-only pairing must not ask for ACCESS_LOCAL_NETWORK")
assertEquals(AiturkPairStep.CONNECTING, state.step)
assertNull(state.errorRes)
}
@Test
fun relayOnlyDeepLinkNeverAsksEither() {
val vm = viewModel()
vm.onDeepLink("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId")
assertEquals("turkservis.online", vm.uiState.value.confirmAddress)
vm.confirmDeepLink(lanPermissionGranted = false)
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.CONNECTING, vm.uiState.value.step)
}
@Test
fun qrWithALanAddressStillAsksBeforeConnecting() {
val vm = viewModel()
vm.onQrScanned("aiturk://pair?v=1&u=192.168.1.20:47321&fp=$fp&c=ABCDEFGH&r=$relayId", lanPermissionGranted = false)
assertTrue(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.IDLE, vm.uiState.value.step)
// Denying it no longer ends the pairing: the tunnel is still tried.
vm.onLanPermissionResult(granted = false)
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.CONNECTING, vm.uiState.value.step)
assertNull(vm.uiState.value.errorRes)
}
@Test
fun typedCodeResolvesFirstWithoutAskingForThePermission() {
val vm = viewModel()
vm.onCodeChange("ABCDEFGH")
vm.submitCode(lanPermissionGranted = false)
// Whether a LAN address exists is only known after `resolve`.
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.RESOLVING, vm.uiState.value.step)
}
@Test
fun everyFailureHasItsOwnText() {
val texts = AiturkPairError.entries.associateWith(AiturkPairViewModel::pairErrorRes)
assertEquals(R.string.aiturk_pair_error_host_offline, texts[AiturkPairError.HOST_OFFLINE])
assertEquals(R.string.aiturk_pair_error_relay_unknown, texts[AiturkPairError.RELAY_UNKNOWN])
assertEquals(R.string.aiturk_pair_error_no_internet, texts[AiturkPairError.NO_INTERNET])
assertEquals(R.string.aiturk_pair_error_relay_busy, texts[AiturkPairError.RELAY_BUSY])
assertEquals(R.string.aiturk_pair_error_host_timeout, texts[AiturkPairError.HOST_TIMEOUT])
assertEquals(R.string.aiturk_pair_error_tls_pin, texts[AiturkPairError.TLS_PIN])
assertEquals(R.string.aiturk_pair_error_pc_unreachable, texts[AiturkPairError.PC_UNREACHABLE])
assertEquals(R.string.aiturk_pair_error_lan_permission, texts[AiturkPairError.LAN_PERMISSION])
assertEquals(R.string.aiturk_pair_error_wrong_code, texts[AiturkPairError.WRONG_CODE])
assertEquals(R.string.aiturk_pair_error_pairing_closed, texts[AiturkPairError.PAIRING_CLOSED])
assertEquals(R.string.aiturk_pair_error_pairing_locked, texts[AiturkPairError.PAIRING_LOCKED])
// The named failures never collapse into one message.
val distinct =
listOf(
AiturkPairError.HOST_OFFLINE,
AiturkPairError.RELAY_UNKNOWN,
AiturkPairError.NO_INTERNET,
AiturkPairError.RELAY_BUSY,
AiturkPairError.HOST_TIMEOUT,
AiturkPairError.TLS_PIN,
AiturkPairError.PC_UNREACHABLE,
AiturkPairError.LAN_PERMISSION,
AiturkPairError.WRONG_CODE,
AiturkPairError.PAIRING_CLOSED,
AiturkPairError.PAIRING_LOCKED,
AiturkPairError.CODE_NOT_FOUND,
AiturkPairError.RATE_LIMITED,
).map { texts.getValue(it) }
assertEquals(distinct.size, distinct.toSet().size)
assertEquals(R.string.aiturk_pair_error_qr_invalid, AiturkPairViewModel.payloadErrorRes(AiturkPayloadError.BAD_RELAY))
}
@Test
fun securityCodeIsTheFirstEightCharactersOfTheFingerprint() {
assertEquals("AbCdEfGh", AiturkPairViewModel.securityCode("AbCdEfGh" + "x".repeat(35)))
assertEquals(0, tunnelOpens.get())
}
}
@@ -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)
}
}
@@ -0,0 +1,45 @@
package com.m57.hermescontrol.ui.cron
import org.junit.Assert.assertEquals
import org.junit.Test
class ScheduleSelectionTest {
@Test
fun `calendar selections preserve exact time when reopened`() {
val cases = mapOf(
"59 23 * * *" to ScheduleMode.DAILY,
"59 * * * *" to ScheduleMode.HOURLY,
"* * * * *" to ScheduleMode.MINUTELY,
"59 23 * * 1,3,5" to ScheduleMode.WEEKLY,
"59 23 31 * *" to ScheduleMode.MONTHLY,
"59 23 29 2 *" to ScheduleMode.YEARLY,
"0 0 1 1 *" to ScheduleMode.YEARLY,
"every 120m" to ScheduleMode.INTERVAL,
"every 2h" to ScheduleMode.INTERVAL,
"every 3d" to ScheduleMode.INTERVAL,
)
cases.forEach { (expression, mode) ->
val parsed = parseSchedule(expression)
assertEquals(expression, mode, parsed.mode)
assertEquals(expression, expression, parsed.toSchedule())
}
}
@Test
fun `unknown and malformed schedules remain untouched`() {
listOf("every 1s", "every 0m", "every 999999999999999h", "0 9 * * 1,garbage",
"0 9 * * 1,8", "0 9 31 2 *", "99 99 * * *", "2030-01-01T12:00:00",
"*/45 * * * *", "0 */5 * * *", "0 9 * * MON-FRI").forEach { expression ->
val parsed = parseSchedule(expression)
assertEquals(expression, ScheduleMode.ADVANCED, parsed.mode)
assertEquals(expression, expression, parsed.toSchedule())
}
}
@Test
fun `daily time selection builds actual backend expression`() {
assertEquals("59 23 * * *", ParsedSchedule(ScheduleMode.DAILY, hour = 23, minute = 59).toSchedule())
assertEquals("every 90m", ParsedSchedule(ScheduleMode.INTERVAL, intervalCount = 90, intervalUnit = "m").toSchedule())
assertEquals(setOf(0, 6), parseSchedule("0 9 * * 6-7").selectedDays)
}
}
@@ -29,9 +29,13 @@ class CronExpressionFormatterTest {
assertEquals("Every 15 minutes", CronExpressionFormatter.cronToHumanReadable("*/15 * * * *"))
assertEquals("Every Monday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1"))
assertEquals("Every Sunday at 06:00", CronExpressionFormatter.cronToHumanReadable("0 6 * * 0"))
assertEquals("On the 1st of every month at 12:00", CronExpressionFormatter.cronToHumanReadable("0 12 1 * *"))
assertEquals("On January 1st at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 1 1 *"))
assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("0 */2 * * *"))
assertEquals("On the 1st of every month at 12:00", CronExpressionFormatter.cronToHumanReadable("0 12 1 * *"))
assertEquals("On January 1st at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 1 1 *"))
assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("0 */2 * * *"))
assertEquals("Every 10 minutes", CronExpressionFormatter.cronToHumanReadable("every 10m"))
assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("every 2h"))
assertEquals("Every 1 day", CronExpressionFormatter.cronToHumanReadable("every 1d"))
assertEquals("Every Monday, Wednesday, Friday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1,3,5"))
}
@Test
+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.
@@ -0,0 +1,2 @@
24333f8a63b6825ea9c5514f83c2829b004d1fee
+43
View File
@@ -0,0 +1,43 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Official Windows command-line tools 22.0; checksum from developer.android.com/studio.
# Explicit .zip avoids tool-cache's extensionless archive bug on Windows PowerShell.
$sdkRoot = Join-Path $env:RUNNER_TEMP 'android-sdk'
$archive = Join-Path $env:RUNNER_TEMP 'commandlinetools-win-15859902.zip'
$unpack = Join-Path $env:RUNNER_TEMP 'android-tools-extracted'
$expected = '90ae805d20434428bffcb699c290860f19bb5f66a67e6b330067e3de801fb04a'
Invoke-WebRequest 'https://dl.google.com/android/repository/commandlinetools-win-15859902_latest.zip' -OutFile $archive -UseBasicParsing
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected) {
throw 'Android command-line tools checksum mismatch'
}
Expand-Archive -LiteralPath $archive -DestinationPath $unpack
$toolsRoot = Join-Path $sdkRoot 'cmdline-tools'
New-Item -ItemType Directory -Path $toolsRoot -Force | Out-Null
# Both absolute paths are bounded to the runner's fresh temporary directory.
$source = [IO.Path]::GetFullPath((Join-Path $unpack 'cmdline-tools'))
$destination = [IO.Path]::GetFullPath((Join-Path $toolsRoot 'latest'))
$boundary = [IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\') + '\'
foreach ($candidate in @($source, $destination)) {
if (-not $candidate.StartsWith($boundary, [StringComparison]::OrdinalIgnoreCase)) {
throw 'SDK extraction path escaped runner temporary directory'
}
}
Move-Item -LiteralPath $source -Destination $destination
$env:ANDROID_HOME = $sdkRoot
$env:ANDROID_SDK_ROOT = $sdkRoot
$manager = Join-Path $destination 'bin\sdkmanager.bat'
# Transfer the already accepted workstation license, as documented by Google:
# https://developer.android.com/studio/intro/update#download-with-gradle
# The self-hosted runner does not reliably forward stdin to sdkmanager prompts.
$licenses = Join-Path $sdkRoot 'licenses'
New-Item -ItemType Directory -Path $licenses -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'android-licenses\android-sdk-license') -Destination $licenses
& $manager "--sdk_root=$sdkRoot" 'platform-tools' 'platforms;android-37.0' 'build-tools;37.0.0'
if ($LASTEXITCODE -ne 0) { throw 'Android SDK installation failed' }
foreach ($required in @('platforms\android-37.0\android.jar', 'build-tools\37.0.0\aapt2.exe', 'licenses\android-sdk-license')) {
if (-not (Test-Path -LiteralPath (Join-Path $sdkRoot $required))) {
throw "Android SDK setup incomplete: $required"
}
}
"ANDROID_HOME=$sdkRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"ANDROID_SDK_ROOT=$sdkRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Linux CI on the TurkServis server; no workstation Android installation required.
set -euo pipefail
sdk_root="${RUNNER_TEMP:?}/android-sdk"
archive="${RUNNER_TEMP}/commandlinetools-linux-16111833.zip"
mkdir -p "$sdk_root/cmdline-tools" "$sdk_root/licenses"
curl --fail --location --retry 3 --max-time 300 \
https://dl.google.com/android/repository/commandlinetools-linux-16111833_latest.zip -o "$archive"
# Official checksum from Google's repository2-3.xml, cmdline-tools;latest, linux.
printf '%s %s\n' e025545c62a8e64c7559119566a569fb1dec5f60 "$archive" | sha1sum -c -
unzip -q "$archive" -d "$sdk_root/cmdline-tools"
mv "$sdk_root/cmdline-tools/cmdline-tools" "$sdk_root/cmdline-tools/latest"
cp scripts/android-licenses/android-sdk-license "$sdk_root/licenses/"
"$sdk_root/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$sdk_root" \
'platform-tools' 'platforms;android-37.0' 'build-tools;37.0.0'
test -f "$sdk_root/platforms/android-37.0/android.jar"
test -x "$sdk_root/build-tools/37.0.0/aapt2"
printf 'ANDROID_HOME=%s\nANDROID_SDK_ROOT=%s\n' "$sdk_root" "$sdk_root" >> "$GITHUB_ENV"