Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c17c3f04b | ||
|
|
dc01c225f7 | ||
|
|
1f692044d2 | ||
|
|
8161f3bf09 | ||
|
|
1d11afefec | ||
|
|
5006d1c7a8 | ||
|
|
004d4622c4 | ||
|
|
824f0a07e2 | ||
|
|
71dd92a4c2 | ||
|
|
ebdfeddd9a | ||
|
|
017864a0db | ||
|
|
6472d876b3 | ||
|
|
9e4e979e7a | ||
|
|
cb7e6acb59 | ||
|
|
827c74b3c3 |
@@ -0,0 +1,36 @@
|
|||||||
|
name: Android acceptance
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
android:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: https://gitea.twinpay.one
|
||||||
|
- name: Set up Java 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '21'
|
||||||
|
- name: Set up Android SDK
|
||||||
|
shell: powershell
|
||||||
|
run: ./scripts/setup-android-ci.ps1
|
||||||
|
- name: Test and build exact commit
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ((git rev-parse HEAD).Trim() -ne $env:GITHUB_SHA) { throw 'Checkout SHA mismatch' }
|
||||||
|
Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory
|
||||||
|
# A fresh runner compiles both variants from scratch. Keep their compiler
|
||||||
|
# heaps separate in time; the previous shared 2 GiB heap exhausted itself.
|
||||||
|
$buildArgs = @('--no-daemon', '--console=plain', '--max-workers=1', '-Pkotlin.daemon.jvmargs=-Xmx4096m')
|
||||||
|
.\gradlew.bat testDebugUnitTest assembleDebug @buildArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
.\gradlew.bat compileReleaseKotlin @buildArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -21,6 +21,7 @@ android {
|
|||||||
applicationId = "online.turkservis.aiturk.mobil"
|
applicationId = "online.turkservis.aiturk.mobil"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
// Version overrides passed from CI via -PversionName / -PversionCode
|
// Version overrides passed from CI via -PversionName / -PversionCode
|
||||||
// Falls back to defaults for local development.
|
// Falls back to defaults for local development.
|
||||||
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
||||||
@@ -326,6 +327,10 @@ val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent
|
|||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
maxParallelForks = 2
|
maxParallelForks = 2
|
||||||
|
// Source-level expectations use decimal dots; the developer's Windows
|
||||||
|
// display language must not change test output (the APK locale is unaffected).
|
||||||
|
systemProperty("user.language", "en")
|
||||||
|
systemProperty("user.country", "US")
|
||||||
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
|
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
|
||||||
if (!includeBenchmarks) {
|
if (!includeBenchmarks) {
|
||||||
filter { excludeTestsMatching("*Benchmark") }
|
filter { excludeTestsMatching("*Benchmark") }
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.EventListener
|
||||||
|
import okhttp3.Handshake
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Opt-in live Android/Conscrypt regression. Arguments contain no credentials. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AiturkRelayDeviceTest {
|
||||||
|
@Test
|
||||||
|
fun pinnedHelloOverPublicRelay() {
|
||||||
|
val args = InstrumentationRegistry.getArguments()
|
||||||
|
val relayId = args.getString("aiturkRelayId")
|
||||||
|
val fingerprint = args.getString("aiturkFingerprint")
|
||||||
|
assumeTrue("Supply the IDE relay ID and public certificate fingerprint", relayId != null && fingerprint != null)
|
||||||
|
requireNotNull(relayId)
|
||||||
|
requireNotNull(fingerprint)
|
||||||
|
val started = System.nanoTime()
|
||||||
|
fun mark(stage: String) {
|
||||||
|
Log.i("AiturkRelayTest", "$stage ${TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)}ms")
|
||||||
|
}
|
||||||
|
val listener = object : EventListener() {
|
||||||
|
override fun secureConnectStart(call: Call) = mark("TLS start")
|
||||||
|
override fun secureConnectEnd(call: Call, handshake: Handshake?) = mark("TLS complete")
|
||||||
|
override fun requestHeadersEnd(call: Call, request: Request) = mark("HTTP sent")
|
||||||
|
override fun responseHeadersEnd(call: Call, response: Response) = mark("HTTP ${response.code}")
|
||||||
|
override fun callFailed(call: Call, ioe: IOException) = mark("FAILED ${ioe.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
val base = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.eventListener(listener)
|
||||||
|
.build()
|
||||||
|
runBlocking { AuthManager.awaitInitialization() }
|
||||||
|
val hostPort = "$relayId.relay.aiturk.invalid:443"
|
||||||
|
val previousPin = AiturkLanTrust.pinFor(hostPort)
|
||||||
|
AiturkLanTrust.pin(hostPort, fingerprint)
|
||||||
|
val client = if (args.getString("aiturkProductionClient") == "true") {
|
||||||
|
OkHttpProvider.base.newBuilder().eventListener(listener).callTimeout(30, TimeUnit.SECONDS).build()
|
||||||
|
} else {
|
||||||
|
AiturkLanTrust.pinnedClient(base, fingerprint)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
repeat(2) {
|
||||||
|
client.newCall(Request.Builder().url("https://$relayId.relay.aiturk.invalid/aiturk/pair/hello").build())
|
||||||
|
.execute().use { response ->
|
||||||
|
assertEquals(200, response.code)
|
||||||
|
assertTrue(response.body.string().contains("aiturk-ide"))
|
||||||
|
}
|
||||||
|
mark("hello $it complete")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.connectionPool.evictAll()
|
||||||
|
client.dispatcher.executorService.shutdown()
|
||||||
|
if (previousPin == null) AiturkLanTrust.unpin(hostPort) else AiturkLanTrust.pin(hostPort, previousPin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
AuthManager.init(this)
|
||||||
NetworkMonitor.init(this)
|
NetworkMonitor.init(this)
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
|
.init(this)
|
||||||
SessionListCacheStore.init(this)
|
SessionListCacheStore.init(this)
|
||||||
// Reply-notification turn boundaries survive process death (armed before
|
// Reply-notification turn boundaries survive process death (armed before
|
||||||
// each mobile prompt, consumed by the matching completion).
|
// 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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
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.AiturkPairDeepLinkEffect
|
||||||
|
import com.m57.hermescontrol.ui.aiturkpair.AiturkUnavailableScreen
|
||||||
import com.m57.hermescontrol.ui.common.DisableDrawerGestures
|
import com.m57.hermescontrol.ui.common.DisableDrawerGestures
|
||||||
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
||||||
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
||||||
@@ -115,7 +117,11 @@ private fun appEntryProvider(
|
|||||||
|
|
||||||
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
||||||
addEntryProvider(clazz = screen.key::class) {
|
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
|
ScreenRegistry.ALL_SCREENS
|
||||||
.filter { it.drawerSection == section }
|
.filter { it.drawerSection == section }
|
||||||
|
.filter { AiturkFeatureGate.shows(it.key) }
|
||||||
.forEach { entry ->
|
.forEach { entry ->
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(entry.icon, contentDescription = null) },
|
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
|
||||||
|
}
|
||||||
@@ -710,5 +710,6 @@ object AiturkRelayStatus {
|
|||||||
|
|
||||||
fun onSocketOpened() {
|
fun onSocketOpened() {
|
||||||
_hostOffline.value = false
|
_hostOffline.value = false
|
||||||
|
AiturkCompat.onSocketOpened()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -799,8 +799,10 @@ object AuthManager {
|
|||||||
val raw = serverStore.getLatestState().wsAuthParam
|
val raw = serverStore.getLatestState().wsAuthParam
|
||||||
val authParam = if (raw.isBlank()) "token" else raw
|
val authParam = if (raw.isBlank()) "token" else raw
|
||||||
return endpointForBuild().webSocketUrl(
|
return endpointForBuild().webSocketUrl(
|
||||||
|
path = "api/ws",
|
||||||
authParameter = authParam,
|
authParameter = authParam,
|
||||||
credential = getToken().orEmpty(),
|
credential = getToken().orEmpty(),
|
||||||
|
extraParams = com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(activeProfileId.value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ object ApiClient {
|
|||||||
.newBuilder()
|
.newBuilder()
|
||||||
.addInterceptor(authInterceptor)
|
.addInterceptor(authInterceptor)
|
||||||
.addInterceptor(ProfileScopeInterceptor)
|
.addInterceptor(ProfileScopeInterceptor)
|
||||||
|
.addInterceptor(CatalogTimeoutInterceptor)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
.authenticator(TokenRefreshAuthenticator)
|
.authenticator(TokenRefreshAuthenticator)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Cold provider discovery can exceed the ordinary 30-second screen timeout. */
|
||||||
|
object CatalogTimeoutInterceptor : Interceptor {
|
||||||
|
const val TIMEOUT_MS = 120_000L
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val catalog = request.method == "GET" && request.url.encodedPath.endsWith("/api/model/options")
|
||||||
|
val next = if (catalog) chain.withReadTimeout(TIMEOUT_MS.toInt(), TimeUnit.MILLISECONDS) else chain
|
||||||
|
return next.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,10 @@ fun mapHttpError(
|
|||||||
if (code == 401) {
|
if (code == 401) {
|
||||||
return NetworkError.AuthExpired()
|
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
|
// Try to parse FastAPI-style error detail from response body
|
||||||
val detail =
|
val detail =
|
||||||
if (errorBody != null) {
|
if (errorBody != null) {
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ class KanbanEventsClient {
|
|||||||
path = KANBAN_EVENTS_PATH,
|
path = KANBAN_EVENTS_PATH,
|
||||||
authParameter = authParam,
|
authParameter = authParam,
|
||||||
credential = credential,
|
credential = credential,
|
||||||
extraParams = mapOf("since" to cursor.toString(), "board" to board),
|
extraParams = mapOf("since" to cursor.toString(), "board" to board) +
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(AuthManager.activeProfileId.value),
|
||||||
)
|
)
|
||||||
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import com.m57.hermescontrol.data.remote.CatalogTimeoutInterceptor
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
@@ -19,7 +20,7 @@ class ModelOptionsRepository(
|
|||||||
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||||
},
|
},
|
||||||
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
||||||
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
|
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params, timeoutMs = CatalogTimeoutInterceptor.TIMEOUT_MS)
|
||||||
},
|
},
|
||||||
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
||||||
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.ws
|
|||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
@@ -171,6 +172,10 @@ internal class ReplayTracker(
|
|||||||
emitEvent(finalEvent)
|
emitEvent(finalEvent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// Disconnect/profile switch cancels replay; it is not a
|
||||||
|
// remote failure and must not request a stale transcript.
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
onReplayFailure(sid, e)
|
onReplayFailure(sid, e)
|
||||||
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -110,6 +111,11 @@ internal class RpcChannel(
|
|||||||
suppressErrorEvent: Boolean = false,
|
suppressErrorEvent: Boolean = false,
|
||||||
): CompletableDeferred<Any?> {
|
): CompletableDeferred<Any?> {
|
||||||
val deferred = 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 =
|
val id =
|
||||||
sendRequest(method, params) { reqId ->
|
sendRequest(method, params) { reqId ->
|
||||||
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
|
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
|
||||||
@@ -191,7 +197,7 @@ internal class RpcChannel(
|
|||||||
if (error != null) {
|
if (error != null) {
|
||||||
call.deferred.completeExceptionally(
|
call.deferred.completeExceptionally(
|
||||||
HermesWsClient.HermesRpcException(
|
HermesWsClient.HermesRpcException(
|
||||||
message = error.message,
|
message = AiturkCompat.rpcExceptionMessage(error.code, error.message),
|
||||||
code = error.code,
|
code = error.code,
|
||||||
data = error.data,
|
data = error.data,
|
||||||
),
|
),
|
||||||
@@ -332,7 +338,12 @@ internal class RpcChannel(
|
|||||||
|
|
||||||
is WsEvent.RpcError -> {
|
is WsEvent.RpcError -> {
|
||||||
onError(event.id)
|
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
|
// Opt-in suppression: the caller already handles the
|
||||||
// failure through the CompletableDeferred, so the event
|
// failure through the CompletableDeferred, so the event
|
||||||
// copy would only surface a duplicate UI banner for
|
// copy would only surface a duplicate UI banner for
|
||||||
|
|||||||
@@ -659,6 +659,8 @@ internal class WsTransport(
|
|||||||
outboundDrainJob?.cancel()
|
outboundDrainJob?.cancel()
|
||||||
outboundDrainJob = null
|
outboundDrainJob = null
|
||||||
Log.i(TAG, "WebSocket opened")
|
Log.i(TAG, "WebSocket opened")
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
|
||||||
|
.onSocketOpened()
|
||||||
connected.set(true)
|
connected.set(true)
|
||||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
reconnectPolicy.reset()
|
reconnectPolicy.reset()
|
||||||
|
|||||||
@@ -726,6 +726,11 @@ object NotificationHelper {
|
|||||||
) {
|
) {
|
||||||
ChatNotificationService.setAppForeground(foreground)
|
ChatNotificationService.setAppForeground(foreground)
|
||||||
HermesWsClient.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) {
|
if (!foreground) {
|
||||||
BackgroundConnectionController.default.reconcileState()
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.ScanContract
|
||||||
import com.journeyapps.barcodescanner.ScanOptions
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||||
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
|
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
|
||||||
|
|
||||||
@@ -286,6 +288,15 @@ fun AiturkPairScreen(
|
|||||||
TextButton(onClick = onBack, enabled = !state.busy) {
|
TextButton(onClick = onBack, enabled = !state.busy) {
|
||||||
Text(stringResource(R.string.aiturk_pair_back))
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||||
import com.m57.hermescontrol.BuildConfig
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||||
import com.m57.hermescontrol.HistoryScreen
|
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.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
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.ChatAppUpdateSection
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
|
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
|
||||||
@@ -601,20 +603,23 @@ fun ChatScreen(
|
|||||||
modifier = Modifier.testTag("chat_menu_search"),
|
modifier = Modifier.testTag("chat_menu_search"),
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenuItem(
|
// AITURK: the paired IDE has no connectors; the entry would only open an empty sheet.
|
||||||
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
if (AiturkFeatureGate.shows(AccountConnectorsScreen)) {
|
||||||
leadingIcon = {
|
DropdownMenuItem(
|
||||||
Icon(
|
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
||||||
imageVector = Icons.Filled.Extension,
|
leadingIcon = {
|
||||||
contentDescription = null,
|
Icon(
|
||||||
)
|
imageVector = Icons.Filled.Extension,
|
||||||
},
|
contentDescription = null,
|
||||||
onClick = {
|
)
|
||||||
showSessionMenu = false
|
},
|
||||||
connectorsViewModel.show()
|
onClick = {
|
||||||
},
|
showSessionMenu = false
|
||||||
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
connectorsViewModel.show()
|
||||||
)
|
},
|
||||||
|
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
||||||
|
)
|
||||||
|
}
|
||||||
ChatBackgroundConnectionMenuItem(
|
ChatBackgroundConnectionMenuItem(
|
||||||
checked = settingsState.keepConnectedInBackground,
|
checked = settingsState.keepConnectedInBackground,
|
||||||
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
|
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.local.DataScope
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||||
@@ -2317,7 +2318,7 @@ class ChatViewModel(
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Error ($method): $errorMsg",
|
errorMessage = AiturkCompat.rpcBannerText(error, legacy = "Error ($method): $errorMsg"),
|
||||||
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
|
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6038,6 +6039,8 @@ class ChatViewModel(
|
|||||||
) {
|
) {
|
||||||
return@launch
|
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 =
|
val usedResult =
|
||||||
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
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.deltaFrom
|
||||||
import com.m57.hermescontrol.data.model.mergeWith
|
import com.m57.hermescontrol.data.model.mergeWith
|
||||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||||
@@ -997,7 +998,7 @@ object ChatWsEventReducer {
|
|||||||
state =
|
state =
|
||||||
state.copy(
|
state.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Error: ${event.error}",
|
errorMessage = AiturkCompat.rpcBannerText(event.error, legacy = "Error: ${event.error}"),
|
||||||
),
|
),
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ object MediaStreamResolver {
|
|||||||
openFileUriStream(uriString, fallbackMime, consumer)
|
openFileUriStream(uriString, fallbackMime, consumer)
|
||||||
}
|
}
|
||||||
|
|
||||||
uriString.startsWith("/") -> {
|
File(uriString).isAbsolute -> {
|
||||||
openLocalFileStream(uriString, fallbackMime, consumer)
|
openLocalFileStream(uriString, fallbackMime, consumer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +168,11 @@ object MediaStreamResolver {
|
|||||||
fallbackMime: String,
|
fallbackMime: String,
|
||||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||||
): T {
|
): T {
|
||||||
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
|
val file =
|
||||||
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
|
runCatching { File(java.net.URI(uriString)) }.getOrElse {
|
||||||
|
val parsed = Uri.parse(uriString).path ?: throw IOException("Invalid file URI: $uriString")
|
||||||
|
File(parsed)
|
||||||
|
}
|
||||||
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
|
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
|
||||||
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
||||||
return file.inputStream().use { inputStream ->
|
return file.inputStream().use { inputStream ->
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.cron
|
package com.m57.hermescontrol.ui.cron
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -16,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Pause
|
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.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -46,9 +53,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@@ -388,6 +397,12 @@ fun CronJobEditorDialog(
|
|||||||
|
|
||||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
||||||
|
|
||||||
|
LaunchedEffect(state.isNew) {
|
||||||
|
if (state.isNew && state.schedule.isBlank()) {
|
||||||
|
onFieldChange("schedule", "0 9 * * *")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
if (hasChanges && !state.isNew) {
|
if (hasChanges && !state.isNew) {
|
||||||
@@ -416,6 +431,8 @@ fun CronJobEditorDialog(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.cron_edit_title)
|
stringResource(R.string.cron_edit_title)
|
||||||
},
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon =
|
navigationIcon =
|
||||||
@@ -430,13 +447,13 @@ fun CronJobEditorDialog(
|
|||||||
),
|
),
|
||||||
actions = {
|
actions = {
|
||||||
if (!state.isLoading) {
|
if (!state.isLoading) {
|
||||||
IconButton(
|
TextButton(
|
||||||
onClick = onSave,
|
onClick = onSave,
|
||||||
enabled = !state.isSaving,
|
enabled = !state.isSaving,
|
||||||
) {
|
) {
|
||||||
if (state.isSaving) {
|
if (state.isSaving) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.fillMaxSize(0.6f),
|
modifier = Modifier.size(24.dp),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
@@ -504,13 +521,9 @@ fun CronJobEditorDialog(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Schedule (required)
|
// Schedule (required)
|
||||||
OutlinedTextField(
|
SchedulePickerSection(
|
||||||
value = state.schedule,
|
schedule = state.schedule,
|
||||||
onValueChange = { onFieldChange("schedule", it) },
|
onScheduleChange = { 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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prompt
|
// Prompt
|
||||||
@@ -818,6 +831,7 @@ private fun BlueprintStartFromDropdown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun BlueprintSlotField(
|
private fun BlueprintSlotField(
|
||||||
field: CronBlueprintField,
|
field: CronBlueprintField,
|
||||||
@@ -837,13 +851,61 @@ private fun BlueprintSlotField(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
field.type == "enum" || field.type == "weekdays" -> {
|
field.type == "enum" || field.type == "weekdays" -> {
|
||||||
ExposedDropdownField(
|
ExposedDropdownField(
|
||||||
label = field.label,
|
label = field.label,
|
||||||
options = field.options,
|
options = field.options,
|
||||||
selectedValue = value,
|
selectedValue = value,
|
||||||
onOptionSelected = onValueChange,
|
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 -> {
|
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 {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall { ApiClient.hermesApi.triggerCronJob(id) }
|
// The server may have started the job even if its reply is lost.
|
||||||
|
safeApiCall(retries = 0) { ApiClient.hermesApi.triggerCronJob(id) }
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
@@ -466,7 +467,7 @@ class CronJobsViewModel :
|
|||||||
val result =
|
val result =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
||||||
safeApiCall {
|
safeApiCall(retries = 0) {
|
||||||
ApiClient.hermesApi.instantiateBlueprint(
|
ApiClient.hermesApi.instantiateBlueprint(
|
||||||
InstantiateBlueprintRequest(
|
InstantiateBlueprintRequest(
|
||||||
blueprint = editor.selectedBlueprintKey,
|
blueprint = editor.selectedBlueprintKey,
|
||||||
@@ -540,7 +541,7 @@ class CronJobsViewModel :
|
|||||||
*/
|
*/
|
||||||
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
||||||
val createdResult =
|
val createdResult =
|
||||||
safeApiCall {
|
safeApiCall(retries = 0) {
|
||||||
ApiClient.hermesApi.createCronJob(
|
ApiClient.hermesApi.createCronJob(
|
||||||
CreateCronJobRequest(
|
CreateCronJobRequest(
|
||||||
name = editor.name,
|
name = editor.name,
|
||||||
|
|||||||
@@ -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.SettingsLanguage
|
||||||
import com.m57.hermescontrol.SettingsVault
|
import com.m57.hermescontrol.SettingsVault
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
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.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.settings.components.SupportSection
|
import com.m57.hermescontrol.ui.settings.components.SupportSection
|
||||||
@@ -74,7 +75,7 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
SettingsCategoryCard(
|
SettingsCategoryCard(
|
||||||
items =
|
items =
|
||||||
listOf(
|
listOfNotNull(
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = Icons.AutoMirrored.Filled.ListAlt,
|
icon = Icons.AutoMirrored.Filled.ListAlt,
|
||||||
label = stringResource(R.string.settings_sec_connection),
|
label = stringResource(R.string.settings_sec_connection),
|
||||||
@@ -94,7 +95,7 @@ fun SettingsScreen(
|
|||||||
label = stringResource(R.string.screen_account_connectors),
|
label = stringResource(R.string.screen_account_connectors),
|
||||||
summary = stringResource(R.string.account_connectors_summary),
|
summary = stringResource(R.string.account_connectors_summary),
|
||||||
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
|
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
|
||||||
),
|
).takeIf { AiturkFeatureGate.shows(AccountConnectorsScreen) },
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Palette,
|
icon = Icons.Filled.Palette,
|
||||||
label = stringResource(R.string.settings_sec_appearance),
|
label = stringResource(R.string.settings_sec_appearance),
|
||||||
@@ -136,7 +137,7 @@ fun SettingsScreen(
|
|||||||
label = stringResource(R.string.settings_sec_vault),
|
label = stringResource(R.string.settings_sec_vault),
|
||||||
summary = stringResource(R.string.settings_summary_vault),
|
summary = stringResource(R.string.settings_summary_vault),
|
||||||
onClick = { NavigationController.navigateTo(SettingsVault) },
|
onClick = { NavigationController.navigateTo(SettingsVault) },
|
||||||
),
|
).takeIf { AiturkFeatureGate.shows(SettingsVault) },
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Info,
|
icon = Icons.Filled.Info,
|
||||||
label = stringResource(R.string.settings_sec_about),
|
label = stringResource(R.string.settings_sec_about),
|
||||||
|
|||||||
@@ -24,6 +24,22 @@ object CronExpressionFormatter {
|
|||||||
|
|
||||||
fun cronToHumanReadable(schedule: String): String {
|
fun cronToHumanReadable(schedule: String): String {
|
||||||
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
|
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(" ")
|
val parts = clean.split(" ")
|
||||||
if (parts.size != 5) {
|
if (parts.size != 5) {
|
||||||
return schedule
|
return schedule
|
||||||
@@ -134,9 +150,13 @@ object CronExpressionFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val dows = parseField(dowPart, 0, 7)
|
val dows = parseField(dowPart, 0, 7)
|
||||||
if (dows.size == 1) {
|
if (dows.isNotEmpty()) {
|
||||||
val dowName = dowNames[dows[0]]
|
val names = dows.map { dowNames[it] }.distinct()
|
||||||
return "Every $dowName at $timeStr"
|
return if (names.size == 1) {
|
||||||
|
"Every " + names[0] + " at " + timeStr
|
||||||
|
} else {
|
||||||
|
"Every " + names.joinToString(", ") + " at " + timeStr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
<string name="aiturk_backend_not_ready">Seçili profil AITURK IDE’de henüz hazır değil. Bilgisayarda IDE’yi açık tutun; kurulum tamamlanınca yeniden deneyin.</string>
|
||||||
<string name="app_name">AITURK Mobil</string>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
@@ -1252,8 +1253,8 @@
|
|||||||
<string name="cron_action_run">Çalıştır</string>
|
<string name="cron_action_run">Çalıştır</string>
|
||||||
<string name="cron_action_edit">Düzenle</string>
|
<string name="cron_action_edit">Düzenle</string>
|
||||||
<string name="cron_action_add">Zamanlanmış görev ekle</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">Görevi düzenle</string>
|
||||||
<string name="cron_edit_title_new">Yeni zamanlanmış görev</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_start_from">Başlangıç şablonu</string>
|
||||||
<string name="cron_edit_blank_job">Boş görev</string>
|
<string name="cron_edit_blank_job">Boş görev</string>
|
||||||
<string name="cron_edit_field_name">Ad</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_hint_skills">Her satıra bir tane</string>
|
||||||
<string name="cron_edit_action_save">Kaydet</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_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_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_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_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_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_empty_desc">Başarım kazanmak için görevleri tamamlayın.</string>
|
||||||
<string name="achievements_label_progress">İlerleme: %1$d / %2$d</string>
|
<string name="achievements_label_progress">İlerleme: %1$d / %2$d</string>
|
||||||
@@ -1906,4 +1930,25 @@
|
|||||||
<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_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_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_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>
|
</resources>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
<string name="aiturk_backend_not_ready">The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes.</string>
|
||||||
<string name="app_name">AITURK Mobil</string>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
@@ -1287,11 +1288,34 @@
|
|||||||
<string name="cron_edit_hint_skills">One per line</string>
|
<string name="cron_edit_hint_skills">One per line</string>
|
||||||
<string name="cron_edit_action_save">Save</string>
|
<string name="cron_edit_action_save">Save</string>
|
||||||
<string name="cron_discard_title">Discard changes?</string>
|
<string name="cron_discard_title">Discard changes?</string>
|
||||||
<string name="cron_discard_message">You have unsaved changes. Discard them?</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_title">Delete job?</string>
|
||||||
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</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_title">No achievements yet</string>
|
||||||
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
|
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
|
||||||
<string name="achievements_label_progress">Progress: %1$d / %2$d</string>
|
<string name="achievements_label_progress">Progress: %1$d / %2$d</string>
|
||||||
@@ -1906,4 +1930,25 @@
|
|||||||
<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_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_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_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>
|
</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,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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Protocol
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class CatalogTimeoutInterceptorTest {
|
||||||
|
@Test
|
||||||
|
fun `only catalog reads receive the discovery timeout including proxy prefixes`() {
|
||||||
|
for (path in listOf("/api/model/options", "/hermes/api/model/options", "/api/cron/jobs", "/api/model/options-extra")) {
|
||||||
|
var timeout = 0
|
||||||
|
val client =
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor(CatalogTimeoutInterceptor)
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
timeout = chain.readTimeoutMillis()
|
||||||
|
Response.Builder()
|
||||||
|
.request(chain.request())
|
||||||
|
.protocol(Protocol.HTTP_1_1)
|
||||||
|
.code(200)
|
||||||
|
.message("OK")
|
||||||
|
.body("{}".toResponseBody())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
client.newCall(Request.Builder().url("https://test.invalid$path").build()).execute().close()
|
||||||
|
assertEquals(if (path.endsWith("/api/model/options")) 120_000 else 30_000, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,9 +98,9 @@ class GatewayFileClientTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `normalizePath expands tilde`() {
|
fun `normalizePath expands tilde only when home is known`() {
|
||||||
val home = System.getenv("HOME") ?: "/home/test"
|
val home = System.getenv("HOME")
|
||||||
assertEquals("$home/foo.png", GatewayFileClient.normalizePath("~/foo.png"))
|
assertEquals(home?.let { "$it/foo.png" }, GatewayFileClient.normalizePath("~/foo.png"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class RawRpcParamsRatchetTest {
|
|||||||
rootDir
|
rootDir
|
||||||
.walkTopDown()
|
.walkTopDown()
|
||||||
.filter { it.isFile && it.extension == "kt" }
|
.filter { it.isFile && it.extension == "kt" }
|
||||||
.filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" }
|
.filter { !it.invariantSeparatorsPath.contains("data/ws/contract") && it.name != "WsMethods.kt" }
|
||||||
.forEach { file ->
|
.forEach { file ->
|
||||||
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
|
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
|
||||||
val code = stripComments(file.readText())
|
val code = stripComments(file.readText())
|
||||||
|
|||||||
@@ -27,6 +27,30 @@ class ReplayTrackerTest {
|
|||||||
|
|
||||||
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
|
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cancelled replay does not report failure or request stale transcript`() = runTest {
|
||||||
|
val started = CompletableDeferred<Unit>()
|
||||||
|
var failures = 0
|
||||||
|
val events = mutableListOf<WsEvent>()
|
||||||
|
val tracker = ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = {
|
||||||
|
started.complete(Unit)
|
||||||
|
kotlinx.coroutines.awaitCancellation()
|
||||||
|
},
|
||||||
|
emitEvent = { events.add(it) },
|
||||||
|
onReplayFailure = { _, _ -> failures++ },
|
||||||
|
)
|
||||||
|
tracker.setSeqWatermark("old-profile", 1)
|
||||||
|
tracker.triggerReplay()
|
||||||
|
started.await()
|
||||||
|
tracker.clear()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(0, failures)
|
||||||
|
assertTrue(events.isEmpty())
|
||||||
|
assertFalse(tracker.isReplayInFlight())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initial state has empty watermarks and no replay in flight`() {
|
fun `initial state has empty watermarks and no replay in flight`() {
|
||||||
val testDispatcher = StandardTestDispatcher()
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
|||||||
@@ -555,4 +555,30 @@ class CronJobsViewModelTest {
|
|||||||
assertEquals("gateway unreachable", job.last_fire_error?.detail)
|
assertEquals("gateway unreachable", job.last_fire_error?.detail)
|
||||||
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
|
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lost create response does not create duplicate scheduled jobs`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.updateEditorField("name", "Retry safety")
|
||||||
|
vm.updateEditorField("schedule", "every 1d")
|
||||||
|
coEvery { mockApi.createCronJob(any()) } throws java.net.SocketTimeoutException("reply lost")
|
||||||
|
vm.saveEditor()
|
||||||
|
settle()
|
||||||
|
coVerify(exactly = 1) { mockApi.createCronJob(any()) }
|
||||||
|
assertTrue(vm.uiState.value.editorState.isOpen)
|
||||||
|
assertFalse(vm.uiState.value.editorState.isSaving)
|
||||||
|
assertNotNull(vm.uiState.value.editorState.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lost trigger response does not run a scheduled job twice`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
coEvery { mockApi.triggerCronJob("j-test") } throws java.net.SocketTimeoutException("reply lost")
|
||||||
|
vm.triggerCronJob("j-test")
|
||||||
|
settle()
|
||||||
|
coVerify(exactly = 1) { mockApi.triggerCronJob("j-test") }
|
||||||
|
assertNotNull(vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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 15 minutes", CronExpressionFormatter.cronToHumanReadable("*/15 * * * *"))
|
||||||
assertEquals("Every Monday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1"))
|
assertEquals("Every Monday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1"))
|
||||||
assertEquals("Every Sunday at 06:00", CronExpressionFormatter.cronToHumanReadable("0 6 * * 0"))
|
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 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("On January 1st at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 1 1 *"))
|
||||||
assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("0 */2 * * *"))
|
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
|
@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
|
||||||
Reference in New Issue
Block a user