From 8161f3bf09a7e00a485b9f2ccf00f7f00965e27c Mon Sep 17 00:00:00 2001 From: yilsem Date: Thu, 8 Oct 2026 02:36:10 +0300 Subject: [PATCH] fix(cron): add repeat dropdown and safe touch time selection --- .../ui/cron/SchedulePickerTest.kt | 77 ++++ .../hermescontrol/ui/cron/CronJobsScreen.kt | 359 +++++++----------- .../ui/cron/ScheduleSelection.kt | 152 ++++++++ app/src/main/res/values-tr/strings.xml | 23 +- app/src/main/res/values/strings.xml | 17 + .../ui/cron/ScheduleSelectionTest.kt | 43 +++ 6 files changed, 454 insertions(+), 217 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/cron/SchedulePickerTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/cron/ScheduleSelection.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/cron/ScheduleSelectionTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/cron/SchedulePickerTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/cron/SchedulePickerTest.kt new file mode 100644 index 00000000..27da0b53 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/cron/SchedulePickerTest.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index 1933bc56..f5718fe6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -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 = 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 = 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 { - 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() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/ScheduleSelection.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/ScheduleSelection.kt new file mode 100644 index 00000000..b2cba5c5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/ScheduleSelection.kt @@ -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 = 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 { + 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() +} diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e3641805..075d4bf3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1292,9 +1292,9 @@ Görev silinsin mi? \"%1$s\" silinsin mi? Bu işlem geri alınamaz. Aralık - Her Gün - Haftalık - Aylık + Her gün + Her hafta + Her ay Gelişmiş Çalışma Saati Cron ifadesini elle düzenle @@ -1934,4 +1934,21 @@ Bilgisayardaki AITURK IDE\'den kullanın ya da AITURK IDE ve AITURK Mobil\'i güncelleyin. Güvenlik nedeniyle bu işlem telefondan yapılamaz. Bilgisayardaki AITURK IDE\'den yapın. AITURK Mobil %1$s + Tekrar + Her saat + Her dakika + Her yıl + Her saniye · sunucu desteklemiyor + Ay + Saatin kaçıncı dakikasında? + Her gün %1$s + Her saat, %1$02d. dakikada + %1$s · %2$s + Her ayın %1$d. günü · %2$s + Her yıl %1$d/%2$d · %3$s + Her %1$d %2$s + Seçilen günün olmadığı ay veya yıllarda çalışmaz. + Aralık + Birim + Saatler bağlı AITURK sunucusunda ayarlı saat dilimine göredir. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eda143af..243e353b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1934,4 +1934,21 @@ Use it in AITURK IDE on your computer, or update AITURK IDE and AITURK Mobil. For security this cannot be done from the phone. Do it in AITURK IDE on your computer. AITURK Mobil %1$s + Repeat + Every hour + Every minute + Every year + Every second · unsupported by server + Month + At which minute of the hour? + Every day at %1$s + Every hour at minute %1$02d + %1$s · %2$s + Day %1$d of each month · %2$s + Every year on %1$d/%2$d · %3$s + Every %1$d %2$s + Skips months or years without the selected date. + Interval + Unit + Times use the time zone configured on the connected AITURK server. diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/ScheduleSelectionTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/ScheduleSelectionTest.kt new file mode 100644 index 00000000..8a8cb402 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/ScheduleSelectionTest.kt @@ -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) + } +}