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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:53:15 +03:00
yilsem 6c17c3f04b test(cron): verify phone editor save/edit/run over real HTTP; clarify server timezone 2026-10-08 04:28:34 +03:00
yilsem dc01c225f7 fix(cron): keep full editor save action readable on phones 2026-10-08 02:57:15 +03:00
yilsem 1f692044d2 fix(cron): preserve chosen clock time across repeat changes 2026-10-08 02:43:32 +03:00
yilsem 8161f3bf09 fix(cron): add repeat dropdown and safe touch time selection 2026-10-08 02:36:10 +03:00
yilsem 1d11afefec feat(cron): visual schedule builder with timepicker and interval presets 2026-10-08 02:17:07 +03:00
11 changed files with 1184 additions and 47 deletions

No files matched your search

+23 -15
View File
@@ -7,30 +7,38 @@ on:
jobs:
android:
runs-on: windows-latest
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
with:
github-server-url: https://gitea.twinpay.one
github-server-url: https://gitea.turkservis.online
- name: Set up Java 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Android SDK
shell: powershell
run: ./scripts/setup-android-ci.ps1
shell: bash
run: bash scripts/setup-android-ci.sh
- name: Test and build exact commit
shell: powershell
shell: bash
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
set -euo pipefail
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
uname -a
free -m
# One compiler process inside the server's bounded, serial CI runner.
build_args=(--no-daemon --console=plain --max-workers=1
'-Dorg.gradle.jvmargs=-Xmx2560m -Dfile.encoding=UTF-8'
'-Pkotlin.compiler.execution.strategy=in-process')
bash gradlew testDebugUnitTest assembleDebug "${build_args[@]}"
bash gradlew compileReleaseKotlin "${build_args[@]}"
- name: Keep exact-commit APK and test reports
uses: actions/upload-artifact@v3
with:
name: aiturk-android-${{ github.sha }}
if-no-files-found: error
path: |
app/build/outputs/apk/debug/app-debug.apk
app/build/reports/tests/testDebugUnitTest
@@ -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) }
}
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.cron
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -16,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Pause
@@ -32,12 +35,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -46,9 +53,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -388,6 +397,12 @@ fun CronJobEditorDialog(
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
LaunchedEffect(state.isNew) {
if (state.isNew && state.schedule.isBlank()) {
onFieldChange("schedule", "0 9 * * *")
}
}
Dialog(
onDismissRequest = {
if (hasChanges && !state.isNew) {
@@ -416,6 +431,8 @@ fun CronJobEditorDialog(
} else {
stringResource(R.string.cron_edit_title)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon =
@@ -430,13 +447,13 @@ fun CronJobEditorDialog(
),
actions = {
if (!state.isLoading) {
IconButton(
TextButton(
onClick = onSave,
enabled = !state.isSaving,
) {
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(0.6f),
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -504,13 +521,9 @@ fun CronJobEditorDialog(
)
// Schedule (required)
OutlinedTextField(
value = state.schedule,
onValueChange = { onFieldChange("schedule", it) },
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
SchedulePickerSection(
schedule = state.schedule,
onScheduleChange = { onFieldChange("schedule", it) },
)
// Prompt
@@ -818,6 +831,7 @@ private fun BlueprintStartFromDropdown(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BlueprintSlotField(
field: CronBlueprintField,
@@ -837,13 +851,61 @@ private fun BlueprintSlotField(
)
}
field.type == "enum" || field.type == "weekdays" -> {
ExposedDropdownField(
label = field.label,
options = field.options,
selectedValue = value,
onOptionSelected = onValueChange,
)
field.type == "enum" || field.type == "weekdays" -> {
ExposedDropdownField(
label = field.label,
options = field.options,
selectedValue = value,
onOptionSelected = onValueChange,
)
}
field.type == "time" -> {
var showTimePicker by remember { mutableStateOf(false) }
val timeParts = value.split(":").mapNotNull { it.toIntOrNull() }
val curHour = (timeParts.getOrNull(0) ?: 8).coerceIn(0, 23)
val curMin = (timeParts.getOrNull(1) ?: 0).coerceIn(0, 59)
Text(field.label, style = MaterialTheme.typography.labelLarge)
TimeDisplayCard(hour = curHour, minute = curMin, onClick = { showTimePicker = true })
if (field.help.isNotBlank()) Text(field.help, style = MaterialTheme.typography.bodySmall)
if (showTimePicker) {
val timePickerState =
rememberTimePickerState(
initialHour = curHour,
initialMinute = curMin,
is24Hour = true,
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.fillMaxWidth().padding(8.dp),
confirmButton = {
TextButton(
onClick = {
onValueChange(String.format(java.util.Locale.ROOT, "%02d:%02d", timePickerState.hour, timePickerState.minute))
showTimePicker = false
},
) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(R.string.action_cancel))
}
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TimePicker(state = timePickerState)
}
},
)
}
}
else -> {
@@ -936,3 +998,528 @@ private fun DeliverField(
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SchedulePickerSection(
schedule: String,
onScheduleChange: (String) -> Unit,
) {
val parsed = remember(schedule) { parseSchedule(schedule) }
var mode by remember(schedule) { mutableStateOf(parsed.mode) }
var hour by remember { mutableStateOf(parsed.hour) }
var minute by remember { mutableStateOf(parsed.minute) }
var intervalCount by remember(schedule) { mutableStateOf(parsed.intervalCount) }
var intervalUnit by remember(schedule) { mutableStateOf(parsed.intervalUnit) }
var selectedDays by remember(schedule) { mutableStateOf(parsed.selectedDays) }
var dayOfMonth by remember(schedule) { mutableStateOf(parsed.dayOfMonth) }
var month by remember(schedule) { mutableStateOf(parsed.month) }
var isAdvanced by remember { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) }
var repeatExpanded by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
fun emitSchedule(
newMode: ScheduleMode,
newHour: Int = hour,
newMinute: Int = minute,
newIntervalCount: Int = intervalCount,
newIntervalUnit: String = intervalUnit,
newDays: Set<Int> = selectedDays,
newDom: Int = dayOfMonth,
newMonth: Int = month,
) {
onScheduleChange(ParsedSchedule(
mode = newMode, hour = newHour, minute = newMinute,
intervalCount = newIntervalCount, intervalUnit = newIntervalUnit,
selectedDays = newDays, dayOfMonth = newDom, month = newMonth, raw = schedule,
).toSchedule())
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.cron_edit_field_schedule),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
TextButton(
onClick = {
isAdvanced = !isAdvanced
if (isAdvanced) {
mode = ScheduleMode.ADVANCED
} else {
val fallback = parseSchedule(schedule)
mode = fallback.mode
hour = fallback.hour
minute = fallback.minute
}
},
) {
Icon(
imageVector = if (isAdvanced) Icons.Filled.Schedule else Icons.Filled.Code,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
if (isAdvanced) {
stringResource(R.string.cron_schedule_visual_toggle)
} else {
stringResource(R.string.cron_schedule_advanced_toggle)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
if (isAdvanced) {
OutlinedTextField(
value = schedule,
onValueChange = onScheduleChange,
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
textStyle =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
)
} else {
val repeatOptions = listOf(
ScheduleMode.DAILY to R.string.cron_schedule_mode_daily,
ScheduleMode.HOURLY to R.string.cron_repeat_hourly,
ScheduleMode.MINUTELY to R.string.cron_repeat_minutely,
ScheduleMode.WEEKLY to R.string.cron_schedule_mode_weekly,
ScheduleMode.MONTHLY to R.string.cron_schedule_mode_monthly,
ScheduleMode.YEARLY to R.string.cron_repeat_yearly,
ScheduleMode.INTERVAL to R.string.cron_schedule_mode_interval,
)
ExposedDropdownMenuBox(
expanded = repeatExpanded,
onExpandedChange = { repeatExpanded = it },
) {
OutlinedTextField(
value = stringResource(repeatOptions.firstOrNull { it.first == mode }?.second
?: R.string.cron_schedule_advanced_toggle),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.cron_repeat_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = repeatExpanded) },
modifier = Modifier.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = repeatExpanded, onDismissRequest = { repeatExpanded = false }) {
repeatOptions.forEach { (option, label) ->
DropdownMenuItem(
text = { Text(stringResource(label)) },
onClick = {
mode = option
repeatExpanded = false
emitSchedule(option)
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.cron_repeat_seconds_unavailable)) },
onClick = {},
enabled = false,
)
}
}
when (mode) {
ScheduleMode.DAILY -> {
TimeDisplayCard(
hour = hour,
minute = minute,
onClick = { showTimePicker = true },
)
}
ScheduleMode.WEEKLY -> {
TimeDisplayCard(
hour = hour,
minute = minute,
onClick = { showTimePicker = true },
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TextButton(
onClick = {
selectedDays = setOf(1, 2, 3, 4, 5)
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
},
) {
Text(stringResource(R.string.cron_schedule_weekdays_workdays), style = MaterialTheme.typography.labelSmall)
}
TextButton(
onClick = {
selectedDays = setOf(6, 0)
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
},
) {
Text(stringResource(R.string.cron_schedule_weekdays_weekend), style = MaterialTheme.typography.labelSmall)
}
TextButton(
onClick = {
selectedDays = setOf(1, 2, 3, 4, 5, 6, 0)
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
},
) {
Text(stringResource(R.string.cron_schedule_weekdays_all), style = MaterialTheme.typography.labelSmall)
}
}
val daysConfig =
listOf(
1 to R.string.cron_dow_mon,
2 to R.string.cron_dow_tue,
3 to R.string.cron_dow_wed,
4 to R.string.cron_dow_thu,
5 to R.string.cron_dow_fri,
6 to R.string.cron_dow_sat,
0 to R.string.cron_dow_sun,
)
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
daysConfig.forEach { (d, labelRes) ->
val isSelected = selectedDays.contains(d)
FilterChip(
selected = isSelected,
onClick = {
val nextDays =
if (isSelected) {
if (selectedDays.size > 1) selectedDays - d else selectedDays
} else {
selectedDays + d
}
selectedDays = nextDays
emitSchedule(ScheduleMode.WEEKLY, newDays = nextDays)
},
label = { Text(stringResource(labelRes)) },
)
}
}
}
}
ScheduleMode.INTERVAL -> {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_count),
options = ((1..120).toList() + intervalCount).distinct().sorted().map { it.toString() },
selectedValue = intervalCount.toString(),
onOptionSelected = {
intervalCount = it.toInt()
emitSchedule(mode, newIntervalCount = intervalCount)
},
)
val units = listOf("m", "h", "d")
val labels = listOf(stringResource(R.string.cron_unit_minute),
stringResource(R.string.cron_unit_hour), stringResource(R.string.cron_unit_day))
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_unit),
options = labels,
selectedValue = labels[units.indexOf(intervalUnit)],
onOptionSelected = {
intervalUnit = units[labels.indexOf(it)]
emitSchedule(mode, newIntervalUnit = intervalUnit)
},
)
Text(
text = stringResource(R.string.cron_schedule_interval_presets),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val presets =
listOf(
Triple(1, "m", "1 " + stringResource(R.string.cron_unit_minute)),
Triple(15, "m", "15 " + stringResource(R.string.cron_unit_minute)),
Triple(30, "m", "30 " + stringResource(R.string.cron_unit_minute)),
Triple(1, "h", "1 " + stringResource(R.string.cron_unit_hour)),
Triple(2, "h", "2 " + stringResource(R.string.cron_unit_hour)),
Triple(6, "h", "6 " + stringResource(R.string.cron_unit_hour)),
Triple(12, "h", "12 " + stringResource(R.string.cron_unit_hour)),
Triple(1, "d", "1 " + stringResource(R.string.cron_unit_day)),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
presets.forEach { (cnt, unt, title) ->
val isSelected = intervalCount == cnt && intervalUnit == unt
FilterChip(
selected = isSelected,
onClick = {
intervalCount = cnt
intervalUnit = unt
emitSchedule(ScheduleMode.INTERVAL, newIntervalCount = cnt, newIntervalUnit = unt)
},
label = { Text(title) },
)
}
}
}
}
ScheduleMode.MONTHLY, ScheduleMode.YEARLY -> {
if (mode == ScheduleMode.YEARLY) {
val months = java.text.DateFormatSymbols(LocalConfiguration.current.locales[0]).months.take(12)
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_month),
options = months,
selectedValue = months[month - 1],
onOptionSelected = {
month = months.indexOf(it) + 1
dayOfMonth = dayOfMonth.coerceAtMost(java.time.Month.of(month).maxLength())
emitSchedule(mode, newDom = dayOfMonth, newMonth = month)
},
)
}
TimeDisplayCard(
hour = hour,
minute = minute,
onClick = { showTimePicker = true },
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.cron_schedule_day_of_month_label, dayOfMonth),
style = MaterialTheme.typography.bodyMedium,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(
onClick = {
if (dayOfMonth > 1) {
dayOfMonth--
emitSchedule(mode, newDom = dayOfMonth)
}
},
enabled = dayOfMonth > 1,
) {
Text("-", style = MaterialTheme.typography.titleLarge)
}
Text(
text = dayOfMonth.toString(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
IconButton(
onClick = {
val maxDay = if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31
if (dayOfMonth < maxDay) {
dayOfMonth++
emitSchedule(mode, newDom = dayOfMonth)
}
},
enabled = dayOfMonth < (if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31),
) {
Text("+", style = MaterialTheme.typography.titleLarge)
}
}
}
}
ScheduleMode.HOURLY -> {
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_minute),
options = (0..59).map { it.toString().padStart(2, '0') },
selectedValue = minute.toString().padStart(2, '0'),
onOptionSelected = { minute = it.toInt(); emitSchedule(mode, newMinute = minute) },
)
}
ScheduleMode.MINUTELY, ScheduleMode.ADVANCED -> Unit
}
if ((mode == ScheduleMode.MONTHLY && dayOfMonth > 28) ||
(mode == ScheduleMode.YEARLY && month == 2 && dayOfMonth == 29)) {
Text(stringResource(R.string.cron_repeat_calendar_note), style = MaterialTheme.typography.bodySmall)
}
}
// Live Summary Card
Text(
stringResource(R.string.cron_schedule_timezone_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = scheduleSummary(parsed, schedule),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
}
}
}
if (showTimePicker) {
val timePickerState =
rememberTimePickerState(
initialHour = hour,
initialMinute = minute,
is24Hour = true,
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.fillMaxWidth().padding(8.dp),
confirmButton = {
TextButton(
onClick = {
hour = timePickerState.hour
minute = timePickerState.minute
showTimePicker = false
emitSchedule(mode, newHour = timePickerState.hour, newMinute = timePickerState.minute)
},
) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(R.string.action_cancel))
}
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TimePicker(state = timePickerState)
}
},
)
}
}
@Composable
private fun TimeDisplayCard(
hour: Int,
minute: Int,
onClick: () -> Unit,
) {
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = stringResource(R.string.cron_schedule_select_time),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = String.format(java.util.Locale.ROOT, "%02d:%02d", hour, minute),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
}
}
Text(
text = stringResource(R.string.cron_action_edit),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun scheduleSummary(parsed: ParsedSchedule, raw: String): String {
val time = java.lang.String.format(java.util.Locale.ROOT, "%02d:%02d", parsed.hour, parsed.minute)
return when (parsed.mode) {
ScheduleMode.DAILY -> stringResource(R.string.cron_summary_daily, time)
ScheduleMode.HOURLY -> stringResource(R.string.cron_summary_hourly, parsed.minute)
ScheduleMode.MINUTELY -> stringResource(R.string.cron_repeat_minutely)
ScheduleMode.WEEKLY -> {
val days = listOf(R.string.cron_dow_sun, R.string.cron_dow_mon, R.string.cron_dow_tue,
R.string.cron_dow_wed, R.string.cron_dow_thu, R.string.cron_dow_fri, R.string.cron_dow_sat)
val names = parsed.selectedDays.sorted().map { stringResource(days[it]) }.joinToString(", ")
stringResource(R.string.cron_summary_weekly, names, time)
}
ScheduleMode.MONTHLY -> stringResource(R.string.cron_summary_monthly, parsed.dayOfMonth, time)
ScheduleMode.YEARLY -> stringResource(R.string.cron_summary_yearly, parsed.dayOfMonth, parsed.month, time)
ScheduleMode.INTERVAL -> {
val unit = stringResource(when (parsed.intervalUnit) {
"m" -> R.string.cron_unit_minute
"h" -> R.string.cron_unit_hour
else -> R.string.cron_unit_day
})
stringResource(R.string.cron_summary_interval, parsed.intervalCount, unit)
}
ScheduleMode.ADVANCED -> raw
}
}
@@ -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()
}
@@ -24,6 +24,22 @@ object CronExpressionFormatter {
fun cronToHumanReadable(schedule: String): String {
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
if (clean.startsWith("every ", ignoreCase = true)) {
val rest = clean.substring(6).trim()
val match = Regex("""^(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(rest)
if (match != null) {
val num = match.groupValues[1].toIntOrNull() ?: 1
val unit = match.groupValues[2].lowercase()
val unitStr = when (unit) {
"m" -> if (num == 1) "minute" else "minutes"
"h" -> if (num == 1) "hour" else "hours"
"d" -> if (num == 1) "day" else "days"
else -> unit
}
return "Every " + num + " " + unitStr
}
return schedule
}
val parts = clean.split(" ")
if (parts.size != 5) {
return schedule
@@ -134,9 +150,13 @@ object CronExpressionFormatter {
}
val dows = parseField(dowPart, 0, 7)
if (dows.size == 1) {
val dowName = dowNames[dows[0]]
return "Every $dowName at $timeStr"
if (dows.isNotEmpty()) {
val names = dows.map { dowNames[it] }.distinct()
return if (names.size == 1) {
"Every " + names[0] + " at " + timeStr
} else {
"Every " + names.joinToString(", ") + " at " + timeStr
}
}
return null
+46 -6
View File
@@ -1253,8 +1253,8 @@
<string name="cron_action_run">Çalıştır</string>
<string name="cron_action_edit">Düzenle</string>
<string name="cron_action_add">Zamanlanmış görev ekle</string>
<string name="cron_edit_title">Zamanlanmış görevi düzenle</string>
<string name="cron_edit_title_new">Yeni zamanlanmış görev</string>
<string name="cron_edit_title">Görevi düzenle</string>
<string name="cron_edit_title_new">Yeni görev</string>
<string name="cron_edit_start_from">Başlangıç şablonu</string>
<string name="cron_edit_blank_job">Boş görev</string>
<string name="cron_edit_field_name">Ad</string>
@@ -1288,11 +1288,34 @@
<string name="cron_edit_hint_skills">Her satıra bir tane</string>
<string name="cron_edit_action_save">Kaydet</string>
<string name="cron_discard_title">Değişiklikler atılsın mı?</string>
<string name="cron_discard_message">Kaydedilmemiş değişiklikler var. Atılsın mı?</string>
<string name="cron_delete_title">Görev silinsin mi?</string>
<string name="cron_delete_message">\"%1$s\" silinsin mi? Bu işlem geri alınamaz.</string>
<string name="cron_discard_message">Kaydedilmemiş değişiklikler var. Atılsın mı?</string>
<string name="cron_delete_title">Görev silinsin mi?</string>
<string name="cron_delete_message">\"%1$s\" silinsin mi? Bu işlem geri alınamaz.</string>
<string name="cron_schedule_mode_interval">Aralık</string>
<string name="cron_schedule_mode_daily">Her gün</string>
<string name="cron_schedule_mode_weekly">Her hafta</string>
<string name="cron_schedule_mode_monthly">Her ay</string>
<string name="cron_schedule_mode_advanced">Gelişmiş</string>
<string name="cron_schedule_select_time">Çalışma Saati</string>
<string name="cron_schedule_advanced_toggle">Cron ifadesini elle düzenle</string>
<string name="cron_schedule_visual_toggle">Görsel zamanlayıcıya dön</string>
<string name="cron_schedule_weekdays_workdays">Hafta içi</string>
<string name="cron_schedule_weekdays_weekend">Hafta sonu</string>
<string name="cron_schedule_weekdays_all">Her gün</string>
<string name="cron_schedule_interval_presets">Hızlı aralıklar</string>
<string name="cron_schedule_day_of_month_label">Ayın %1$d. günü</string>
<string name="cron_dow_mon">Pzt</string>
<string name="cron_dow_tue">Sal</string>
<string name="cron_dow_wed">Çar</string>
<string name="cron_dow_thu">Per</string>
<string name="cron_dow_fri">Cum</string>
<string name="cron_dow_sat">Cmt</string>
<string name="cron_dow_sun">Paz</string>
<string name="cron_unit_minute">dk</string>
<string name="cron_unit_hour">saat</string>
<string name="cron_unit_day">gün</string>
<!-- Achievements Screen -->
<!-- Achievements Screen -->
<string name="achievements_empty_title">Henüz başarım yok</string>
<string name="achievements_empty_desc">Başarım kazanmak için görevleri tamamlayın.</string>
<string name="achievements_label_progress">İlerleme: %1$d / %2$d</string>
@@ -1911,4 +1934,21 @@
<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>
+44 -4
View File
@@ -1288,11 +1288,34 @@
<string name="cron_edit_hint_skills">One per line</string>
<string name="cron_edit_action_save">Save</string>
<string name="cron_discard_title">Discard changes?</string>
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
<string name="cron_delete_title">Delete job?</string>
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
<string name="cron_delete_title">Delete job?</string>
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
<string name="cron_schedule_mode_interval">Interval</string>
<string name="cron_schedule_mode_daily">Daily</string>
<string name="cron_schedule_mode_weekly">Weekly</string>
<string name="cron_schedule_mode_monthly">Monthly</string>
<string name="cron_schedule_mode_advanced">Advanced</string>
<string name="cron_schedule_select_time">Execution Time</string>
<string name="cron_schedule_advanced_toggle">Edit raw cron expression</string>
<string name="cron_schedule_visual_toggle">Switch to visual scheduler</string>
<string name="cron_schedule_weekdays_workdays">Weekdays</string>
<string name="cron_schedule_weekdays_weekend">Weekend</string>
<string name="cron_schedule_weekdays_all">Every day</string>
<string name="cron_schedule_interval_presets">Quick intervals</string>
<string name="cron_schedule_day_of_month_label">Day of month: %1$d</string>
<string name="cron_dow_mon">Mon</string>
<string name="cron_dow_tue">Tue</string>
<string name="cron_dow_wed">Wed</string>
<string name="cron_dow_thu">Thu</string>
<string name="cron_dow_fri">Fri</string>
<string name="cron_dow_sat">Sat</string>
<string name="cron_dow_sun">Sun</string>
<string name="cron_unit_minute">min</string>
<string name="cron_unit_hour">hr</string>
<string name="cron_unit_day">day</string>
<!-- Achievements Screen -->
<!-- Achievements Screen -->
<string name="achievements_empty_title">No achievements yet</string>
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
<string name="achievements_label_progress">Progress: %1$d / %2$d</string>
@@ -1911,4 +1934,21 @@
<string name="aiturk_feature_unavailable_hint">Use it in AITURK IDE on your computer, or update AITURK IDE and AITURK Mobil.</string>
<string name="aiturk_phone_forbidden">For security this cannot be done from the phone. Do it in AITURK IDE on your computer.</string>
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
<string name="cron_repeat_label">Repeat</string>
<string name="cron_repeat_hourly">Every hour</string>
<string name="cron_repeat_minutely">Every minute</string>
<string name="cron_repeat_yearly">Every year</string>
<string name="cron_repeat_seconds_unavailable">Every second · unsupported by server</string>
<string name="cron_repeat_month">Month</string>
<string name="cron_repeat_minute">At which minute of the hour?</string>
<string name="cron_summary_daily">Every day at %1$s</string>
<string name="cron_summary_hourly">Every hour at minute %1$02d</string>
<string name="cron_summary_weekly">%1$s · %2$s</string>
<string name="cron_summary_monthly">Day %1$d of each month · %2$s</string>
<string name="cron_summary_yearly">Every year on %1$d/%2$d · %3$s</string>
<string name="cron_summary_interval">Every %1$d %2$s</string>
<string name="cron_repeat_calendar_note">Skips months or years without the selected date.</string>
<string name="cron_repeat_count">Interval</string>
<string name="cron_repeat_unit">Unit</string>
<string name="cron_schedule_timezone_note">Times use the connected AITURK server’s time zone. Its zone cannot be read here and may differ from your phone’s.</string>
</resources>
@@ -0,0 +1,45 @@
package com.m57.hermescontrol.ui.cron
import org.junit.Assert.assertEquals
import org.junit.Test
class ScheduleSelectionTest {
@Test
fun `calendar selections preserve exact time when reopened`() {
val cases = mapOf(
"59 23 * * *" to ScheduleMode.DAILY,
"59 * * * *" to ScheduleMode.HOURLY,
"* * * * *" to ScheduleMode.MINUTELY,
"59 23 * * 1,3,5" to ScheduleMode.WEEKLY,
"59 23 31 * *" to ScheduleMode.MONTHLY,
"59 23 29 2 *" to ScheduleMode.YEARLY,
"0 0 1 1 *" to ScheduleMode.YEARLY,
"every 120m" to ScheduleMode.INTERVAL,
"every 2h" to ScheduleMode.INTERVAL,
"every 3d" to ScheduleMode.INTERVAL,
)
cases.forEach { (expression, mode) ->
val parsed = parseSchedule(expression)
assertEquals(expression, mode, parsed.mode)
assertEquals(expression, expression, parsed.toSchedule())
}
}
@Test
fun `unknown and malformed schedules remain untouched`() {
listOf("every 1s", "every 0m", "every 999999999999999h", "0 9 * * 1,garbage",
"0 9 * * 1,8", "0 9 31 2 *", "99 99 * * *", "2030-01-01T12:00:00",
"*/45 * * * *", "0 */5 * * *", "0 9 * * MON-FRI").forEach { expression ->
val parsed = parseSchedule(expression)
assertEquals(expression, ScheduleMode.ADVANCED, parsed.mode)
assertEquals(expression, expression, parsed.toSchedule())
}
}
@Test
fun `daily time selection builds actual backend expression`() {
assertEquals("59 23 * * *", ParsedSchedule(ScheduleMode.DAILY, hour = 23, minute = 59).toSchedule())
assertEquals("every 90m", ParsedSchedule(ScheduleMode.INTERVAL, intervalCount = 90, intervalUnit = "m").toSchedule())
assertEquals(setOf(0, 6), parseSchedule("0 9 * * 6-7").selectedDays)
}
}
@@ -29,9 +29,13 @@ class CronExpressionFormatterTest {
assertEquals("Every 15 minutes", CronExpressionFormatter.cronToHumanReadable("*/15 * * * *"))
assertEquals("Every Monday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1"))
assertEquals("Every Sunday at 06:00", CronExpressionFormatter.cronToHumanReadable("0 6 * * 0"))
assertEquals("On the 1st of every month at 12:00", CronExpressionFormatter.cronToHumanReadable("0 12 1 * *"))
assertEquals("On January 1st at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 1 1 *"))
assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("0 */2 * * *"))
assertEquals("On the 1st of every month at 12:00", CronExpressionFormatter.cronToHumanReadable("0 12 1 * *"))
assertEquals("On January 1st at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 1 1 *"))
assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("0 */2 * * *"))
assertEquals("Every 10 minutes", CronExpressionFormatter.cronToHumanReadable("every 10m"))
assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("every 2h"))
assertEquals("Every 1 day", CronExpressionFormatter.cronToHumanReadable("every 1d"))
assertEquals("Every Monday, Wednesday, Friday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1,3,5"))
}
@Test
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Linux CI on the TurkServis server; no workstation Android installation required.
set -euo pipefail
sdk_root="${RUNNER_TEMP:?}/android-sdk"
archive="${RUNNER_TEMP}/commandlinetools-linux-16111833.zip"
mkdir -p "$sdk_root/cmdline-tools" "$sdk_root/licenses"
curl --fail --location --retry 3 --max-time 300 \
https://dl.google.com/android/repository/commandlinetools-linux-16111833_latest.zip -o "$archive"
# Official checksum from Google's repository2-3.xml, cmdline-tools;latest, linux.
printf '%s %s\n' e025545c62a8e64c7559119566a569fb1dec5f60 "$archive" | sha1sum -c -
unzip -q "$archive" -d "$sdk_root/cmdline-tools"
mv "$sdk_root/cmdline-tools/cmdline-tools" "$sdk_root/cmdline-tools/latest"
cp scripts/android-licenses/android-sdk-license "$sdk_root/licenses/"
"$sdk_root/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$sdk_root" \
'platform-tools' 'platforms;android-37.0' 'build-tools;37.0.0'
test -f "$sdk_root/platforms/android-37.0/android.jar"
test -x "$sdk_root/build-tools/37.0.0/aapt2"
printf 'ANDROID_HOME=%s\nANDROID_SDK_ROOT=%s\n' "$sdk_root" "$sdk_root" >> "$GITHUB_ENV"