fix(cron): add repeat dropdown and safe touch time selection

This commit is contained in:
yilsem committed 2026-10-08 02:36:10 +03:00
1 parent 1d11afefec
commit 8161f3bf09
6 files changed
+454 -217

No files matched your search

@@ -0,0 +1,77 @@
package com.m57.hermescontrol.ui.cron
import android.graphics.Bitmap
import android.content.res.Configuration
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.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
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.Rule
import org.junit.Test
class SchedulePickerTest {
@get:Rule val compose = createComposeRule()
@Test
fun repeatDropdownAndClockPreserveSchedule() {
var schedule by mutableStateOf("59 23 * * *")
val baseContext = InstrumentationRegistry.getInstrumentation().targetContext
val configuration = Configuration(baseContext.resources.configuration).apply {
setLocale(java.util.Locale.forLanguageTag("tr-TR"))
}
val context = baseContext.createConfigurationContext(configuration)
compose.setContent {
CompositionLocalProvider(LocalContext provides context, LocalConfiguration provides configuration) {
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()
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.onNodeWithText("55").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_minutely))[0].performClick()
compose.runOnIdle { assertEquals("* * * * *", schedule) }
}
}
@@ -53,6 +53,7 @@ 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
@@ -827,6 +828,7 @@ private fun BlueprintStartFromDropdown(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BlueprintSlotField(
field: CronBlueprintField,
@@ -858,37 +860,12 @@ private fun BlueprintSlotField(
field.type == "time" -> {
var showTimePicker by remember { mutableStateOf(false) }
val timeParts = value.split(":").mapNotNull { it.toIntOrNull() }
val curHour = if (timeParts.isNotEmpty()) timeParts[0] else 8
val curMin = if (timeParts.size > 1) timeParts[1] else 0
val curHour = (timeParts.getOrNull(0) ?: 8).coerceIn(0, 23)
val curMin = (timeParts.getOrNull(1) ?: 0).coerceIn(0, 59)
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(field.label) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_time)) },
trailingIcon = {
IconButton(onClick = { showTimePicker = true }) {
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = stringResource(R.string.cron_schedule_select_time),
tint = MaterialTheme.colorScheme.primary,
)
}
},
supportingText =
if (field.help.isNotBlank()) {
{
Text(
text = field.help,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
} else {
null
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
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 =
@@ -899,10 +876,12 @@ private fun BlueprintSlotField(
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.fillMaxWidth().padding(8.dp),
confirmButton = {
TextButton(
onClick = {
onValueChange(String.format("%02d:%02d", timePickerState.hour, timePickerState.minute))
onValueChange(String.format(java.util.Locale.ROOT, "%02d:%02d", timePickerState.hour, timePickerState.minute))
showTimePicker = false
},
) {
@@ -1018,7 +997,7 @@ private fun DeliverField(
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SchedulePickerSection(
internal fun SchedulePickerSection(
schedule: String,
onScheduleChange: (String) -> Unit,
) {
@@ -1031,7 +1010,9 @@ private fun SchedulePickerSection(
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 isAdvanced by remember(schedule) { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) }
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(
@@ -1042,19 +1023,13 @@ private fun SchedulePickerSection(
newIntervalUnit: String = intervalUnit,
newDays: Set<Int> = selectedDays,
newDom: Int = dayOfMonth,
newMonth: Int = month,
) {
val built =
when (newMode) {
ScheduleMode.DAILY -> "$newMinute $newHour * * *"
ScheduleMode.WEEKLY -> {
val daysStr = (if (newDays.isEmpty()) listOf(1) else newDays.sorted()).joinToString(",")
"$newMinute $newHour * * $daysStr"
}
ScheduleMode.MONTHLY -> "$newMinute $newHour $newDom * *"
ScheduleMode.INTERVAL -> "every $newIntervalCount$newIntervalUnit"
ScheduleMode.ADVANCED -> schedule
}
onScheduleChange(built)
onScheduleChange(ParsedSchedule(
mode = newMode, hour = newHour, minute = newMinute,
intervalCount = newIntervalCount, intervalUnit = newIntervalUnit,
selectedDays = newDays, dayOfMonth = newDom, month = newMonth, raw = schedule,
).toSchedule())
}
Column(
@@ -1079,8 +1054,7 @@ private fun SchedulePickerSection(
mode = ScheduleMode.ADVANCED
} else {
val fallback = parseSchedule(schedule)
mode = if (fallback.mode == ScheduleMode.ADVANCED) ScheduleMode.DAILY else fallback.mode
emitSchedule(mode)
mode = fallback.mode
}
},
) {
@@ -1116,45 +1090,46 @@ private fun SchedulePickerSection(
),
)
} else {
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
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 },
) {
FilterChip(
selected = mode == ScheduleMode.DAILY,
onClick = {
mode = ScheduleMode.DAILY
emitSchedule(ScheduleMode.DAILY)
},
label = { Text(stringResource(R.string.cron_schedule_mode_daily)) },
)
FilterChip(
selected = mode == ScheduleMode.WEEKLY,
onClick = {
mode = ScheduleMode.WEEKLY
emitSchedule(ScheduleMode.WEEKLY)
},
label = { Text(stringResource(R.string.cron_schedule_mode_weekly)) },
)
FilterChip(
selected = mode == ScheduleMode.INTERVAL,
onClick = {
mode = ScheduleMode.INTERVAL
emitSchedule(ScheduleMode.INTERVAL)
},
label = { Text(stringResource(R.string.cron_schedule_mode_interval)) },
)
FilterChip(
selected = mode == ScheduleMode.MONTHLY,
onClick = {
mode = ScheduleMode.MONTHLY
emitSchedule(ScheduleMode.MONTHLY)
},
label = { Text(stringResource(R.string.cron_schedule_mode_monthly)) },
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) {
@@ -1178,6 +1153,7 @@ private fun SchedulePickerSection(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TextButton(
@@ -1249,6 +1225,27 @@ private fun SchedulePickerSection(
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,
@@ -1256,6 +1253,7 @@ private fun SchedulePickerSection(
)
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)),
@@ -1287,7 +1285,20 @@ private fun SchedulePickerSection(
}
}
ScheduleMode.MONTHLY -> {
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,
@@ -1311,7 +1322,7 @@ private fun SchedulePickerSection(
onClick = {
if (dayOfMonth > 1) {
dayOfMonth--
emitSchedule(ScheduleMode.MONTHLY, newDom = dayOfMonth)
emitSchedule(mode, newDom = dayOfMonth)
}
},
enabled = dayOfMonth > 1,
@@ -1325,12 +1336,13 @@ private fun SchedulePickerSection(
)
IconButton(
onClick = {
if (dayOfMonth < 31) {
val maxDay = if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31
if (dayOfMonth < maxDay) {
dayOfMonth++
emitSchedule(ScheduleMode.MONTHLY, newDom = dayOfMonth)
emitSchedule(mode, newDom = dayOfMonth)
}
},
enabled = dayOfMonth < 31,
enabled = dayOfMonth < (if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31),
) {
Text("+", style = MaterialTheme.typography.titleLarge)
}
@@ -1338,11 +1350,28 @@ private fun SchedulePickerSection(
}
}
ScheduleMode.ADVANCED -> Unit
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 =
@@ -1366,18 +1395,10 @@ private fun SchedulePickerSection(
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = CronExpressionFormatter.cronToHumanReadable(schedule.ifBlank { "0 9 * * *" }),
text = scheduleSummary(parsed, schedule),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Text(
text = schedule.ifBlank { "0 9 * * *" },
style =
MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
}
}
@@ -1392,6 +1413,8 @@ private fun SchedulePickerSection(
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.fillMaxWidth().padding(8.dp),
confirmButton = {
TextButton(
onClick = {
@@ -1453,7 +1476,7 @@ private fun TimeDisplayCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = String.format("%02d:%02d", hour, minute),
text = String.format(java.util.Locale.ROOT, "%02d:%02d", hour, minute),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
@@ -1469,121 +1492,29 @@ private fun TimeDisplayCard(
}
}
private enum class ScheduleMode {
DAILY,
WEEKLY,
INTERVAL,
MONTHLY,
ADVANCED,
}
private 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, 2, 3, 4, 5),
val dayOfMonth: Int = 1,
val raw: String = "",
)
private 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() ?: 1
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) {
val count = minStepMatch.groupValues[1].toIntOrNull() ?: 15
return ParsedSchedule(
mode = ScheduleMode.INTERVAL,
intervalCount = count,
intervalUnit = "m",
)
}
val hourStepMatch = Regex("""^0\s+\*/(\d+)\s+\*\s+\*\s+\*$""").find(clean)
if (hourStepMatch != null) {
val count = hourStepMatch.groupValues[1].toIntOrNull() ?: 1
return ParsedSchedule(
mode = ScheduleMode.INTERVAL,
intervalCount = count,
intervalUnit = "h",
)
}
val parts = clean.split(" ")
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) {
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,
)
}
}
@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)
}
}
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
}
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()
}
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
}
return dow.split(",")
.mapNotNull { it.trim().toIntOrNull() }
.filter { it in 0..7 }
.map { if (it == 7) 0 else it }
.distinct()
.sorted()
}
@@ -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, 2, 3, 4, 5),
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()
}
+20 -3
View File
@@ -1292,9 +1292,9 @@
<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">Haftalık</string>
<string name="cron_schedule_mode_monthly">Aylı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>
@@ -1934,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 sunucusunda ayarlı saat dilimine göredir.</string>
</resources>
+17
View File
@@ -1934,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 time zone configured on the connected AITURK server.</string>
</resources>
@@ -0,0 +1,43 @@
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,
)
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)
}
}