Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47a7e35fea | ||
|
|
8b485c0bad | ||
|
|
6c17c3f04b | ||
|
|
dc01c225f7 | ||
|
|
1f692044d2 | ||
|
|
8161f3bf09 | ||
|
|
1d11afefec | ||
|
|
5006d1c7a8 | ||
|
|
004d4622c4 | ||
|
|
824f0a07e2 | ||
|
|
71dd92a4c2 | ||
|
|
ebdfeddd9a | ||
|
|
017864a0db | ||
|
|
6472d876b3 | ||
|
|
9e4e979e7a | ||
|
|
cb7e6acb59 | ||
|
|
827c74b3c3 | ||
|
|
c2d828b90a | ||
|
|
cbe716300c | ||
|
|
9afac6f508 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user