Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47a7e35fea | ||
|
|
8b485c0bad | ||
|
|
6c17c3f04b | ||
|
|
dc01c225f7 | ||
|
|
1f692044d2 | ||
|
|
8161f3bf09 | ||
|
|
1d11afefec |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Linux CI on the TurkServis server; no workstation Android installation required.
|
||||
set -euo pipefail
|
||||
sdk_root="${RUNNER_TEMP:?}/android-sdk"
|
||||
archive="${RUNNER_TEMP}/commandlinetools-linux-16111833.zip"
|
||||
mkdir -p "$sdk_root/cmdline-tools" "$sdk_root/licenses"
|
||||
curl --fail --location --retry 3 --max-time 300 \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-16111833_latest.zip -o "$archive"
|
||||
# Official checksum from Google's repository2-3.xml, cmdline-tools;latest, linux.
|
||||
printf '%s %s\n' e025545c62a8e64c7559119566a569fb1dec5f60 "$archive" | sha1sum -c -
|
||||
unzip -q "$archive" -d "$sdk_root/cmdline-tools"
|
||||
mv "$sdk_root/cmdline-tools/cmdline-tools" "$sdk_root/cmdline-tools/latest"
|
||||
cp scripts/android-licenses/android-sdk-license "$sdk_root/licenses/"
|
||||
"$sdk_root/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$sdk_root" \
|
||||
'platform-tools' 'platforms;android-37.0' 'build-tools;37.0.0'
|
||||
test -f "$sdk_root/platforms/android-37.0/android.jar"
|
||||
test -x "$sdk_root/build-tools/37.0.0/aapt2"
|
||||
printf 'ANDROID_HOME=%s\nANDROID_SDK_ROOT=%s\n' "$sdk_root" "$sdk_root" >> "$GITHUB_ENV"
|
||||
Reference in new issue
Block a user