diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6fc1ab655..9ab4f8857b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -152,6 +152,11 @@ android:configChanges="uiMode" android:exported="false" android:label="@string/app" /> + (), Behavior { } } design.requests.onReceive { - ApplicationObserver.createdActivities.forEach { - it.recreate() + when (it) { + AppSettingsDesign.Request.ReCreateAllActivities -> + ApplicationObserver.createdActivities.forEach { activity -> + activity.recreate() + } + + AppSettingsDesign.Request.OpenAutoSwitchSettings -> + startActivity(AutoSwitchSettingsActivity::class.intent) } } } diff --git a/app/src/main/java/com/github/kr328/clash/AutoSwitchSettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/AutoSwitchSettingsActivity.kt new file mode 100644 index 0000000000..1ecd78ac65 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/AutoSwitchSettingsActivity.kt @@ -0,0 +1,15 @@ +package com.github.kr328.clash + +import com.github.kr328.clash.design.AutoSwitchSettingsDesign +import com.github.kr328.clash.service.store.ServiceStore + +class AutoSwitchSettingsActivity : BaseActivity() { + override suspend fun main() { + val design = AutoSwitchSettingsDesign( + this, + ServiceStore(this), + ) + + setContentDesign(design) + } +} diff --git a/common/src/main/java/com/github/kr328/clash/common/constants/Intents.kt b/common/src/main/java/com/github/kr328/clash/common/constants/Intents.kt index b754dc701a..07b423a124 100644 --- a/common/src/main/java/com/github/kr328/clash/common/constants/Intents.kt +++ b/common/src/main/java/com/github/kr328/clash/common/constants/Intents.kt @@ -23,6 +23,8 @@ object Intents { val ACTION_PROFILE_SCHEDULE_UPDATES = "$packageName.intent.action.SCHEDULE_UPDATES" val ACTION_PROFILE_LOADED = "$packageName.intent.action.PROFILE_LOADED" val ACTION_OVERRIDE_CHANGED = "$packageName.intent.action.OVERRIDE_CHANGED" + val ACTION_AUTO_SWITCH_RESCHEDULE = "$packageName.intent.action.AUTO_SWITCH_RESCHEDULE" + val ACTION_AUTO_SWITCH_TRIGGER = "$packageName.intent.action.AUTO_SWITCH_TRIGGER" const val EXTRA_STOP_REASON = "stop_reason" const val EXTRA_UUID = "uuid" diff --git a/design/src/main/java/com/github/kr328/clash/design/AppSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AppSettingsDesign.kt index fdef937d58..dd3deecdfc 100644 --- a/design/src/main/java/com/github/kr328/clash/design/AppSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/AppSettingsDesign.kt @@ -22,7 +22,8 @@ class AppSettingsDesign( onHideIconChange: (hide: Boolean) -> Unit, ) : Design(context) { enum class Request { - ReCreateAllActivities + ReCreateAllActivities, + OpenAutoSwitchSettings, } private val binding = DesignSettingsCommonBinding @@ -87,6 +88,16 @@ class AppSettingsDesign( ) { enabled = !running } + + clickable( + title = R.string.auto_switch, + icon = R.drawable.ic_baseline_schedule, + summary = R.string.auto_switch_summary, + ) { + clicked { + requests.trySend(Request.OpenAutoSwitchSettings) + } + } } binding.content.addView(screen.root) diff --git a/design/src/main/java/com/github/kr328/clash/design/AutoSwitchSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AutoSwitchSettingsDesign.kt new file mode 100644 index 0000000000..060d33f78e --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/AutoSwitchSettingsDesign.kt @@ -0,0 +1,275 @@ +package com.github.kr328.clash.design + +import android.app.TimePickerDialog +import android.content.Context +import android.text.format.DateFormat +import android.view.View +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding +import com.github.kr328.clash.design.databinding.PreferenceCategoryBinding +import com.github.kr328.clash.design.preference.ClickablePreference +import com.github.kr328.clash.design.preference.OnChangedListener +import com.github.kr328.clash.design.preference.Preference +import com.github.kr328.clash.design.preference.PreferenceScreen +import com.github.kr328.clash.design.preference.category +import com.github.kr328.clash.design.preference.clickable +import com.github.kr328.clash.design.preference.preferenceScreen +import com.github.kr328.clash.design.preference.selectableList +import com.github.kr328.clash.design.util.applyFrom +import com.github.kr328.clash.design.util.bindAppBarElevation +import com.github.kr328.clash.design.util.layoutInflater +import com.github.kr328.clash.design.util.root +import com.github.kr328.clash.service.AutoSwitchReceiver +import com.github.kr328.clash.service.model.AutoSwitchStrategyType +import com.github.kr328.clash.service.model.DailyAutoSwitchSchedule +import com.github.kr328.clash.service.model.WeekDay +import com.github.kr328.clash.service.model.WeeklyAutoSwitchSchedule +import com.github.kr328.clash.service.store.ServiceStore +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.text.DateFormatSymbols +import java.util.Calendar +import java.util.EnumMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +class AutoSwitchSettingsDesign( + context: Context, + private val serviceStore: ServiceStore, +) : Design(context) { + private val binding = DesignSettingsCommonBinding + .inflate(context.layoutInflater, context.root, false) + + override val root: View + get() = binding.root + + private var currentStrategy: AutoSwitchStrategyType = AutoSwitchStrategyType.None + private var weeklySchedule: WeeklyAutoSwitchSchedule = WeeklyAutoSwitchSchedule() + + private val dayPreferences = EnumMap(WeekDay::class.java) + private lateinit var weeklyHeader: Preference + + init { + binding.surface = surface + binding.activityBarLayout.applyFrom(context) + binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout) + + val screen = preferenceScreen(context) { + category(R.string.auto_switch_strategy_category) + + selectableList( + value = serviceStore::autoSwitchStrategy, + values = AutoSwitchStrategyType.values(), + valuesText = arrayOf( + R.string.auto_switch_strategy_none, + R.string.auto_switch_strategy_weekly, + ), + title = R.string.auto_switch_strategy_title, + icon = R.drawable.ic_baseline_schedule, + ) { + listener = OnChangedListener { + refreshStrategy() + } + } + + weeklyHeader = addCategory(R.string.auto_switch_weekly_category) + + WeekDay.values().forEach { day -> + val preference = clickable( + title = R.string.auto_switch_day_placeholder, + ) { + launch(Dispatchers.Main) { + showDayOptions(day) + } + } + + preference.title = day.displayName(context) + dayPreferences[day] = preference + } + } + + binding.content.addView(screen.root) + + launch(Dispatchers.Main) { + val (strategy, schedule) = withContext(Dispatchers.IO) { + serviceStore.autoSwitchStrategy to serviceStore.autoSwitchWeeklySchedule + } + + currentStrategy = strategy + weeklySchedule = schedule + + WeekDay.values().forEach(::updateDaySummary) + updateStrategyVisibility() + } + } + + private fun refreshStrategy() { + launch(Dispatchers.Main) { + val strategy = withContext(Dispatchers.IO) { + serviceStore.autoSwitchStrategy.also { + AutoSwitchReceiver.requestReschedule(context) + } + } + + currentStrategy = strategy + updateStrategyVisibility() + } + } + + private fun updateStrategyVisibility() { + val visible = currentStrategy == AutoSwitchStrategyType.Weekly + val visibility = if (visible) View.VISIBLE else View.GONE + + weeklyHeader.view.visibility = visibility + dayPreferences.values.forEach { preference -> + preference.view.visibility = visibility + } + } + + private fun updateDaySummary(day: WeekDay) { + val preference = dayPreferences[day] ?: return + val schedule = weeklySchedule.get(day) + val startText = formatTime(schedule.startMinutes) + val stopText = formatTime(schedule.stopMinutes) + + preference.summary = context.getString( + R.string.auto_switch_day_summary, + startText, + stopText, + ) + } + + private suspend fun requestTime(initial: Int?): Int? = suspendCancellableCoroutine { cont -> + val is24Hour = DateFormat.is24HourFormat(context) + val initialHour = initial?.div(60) ?: 0 + val initialMinute = initial?.rem(60) ?: 0 + var resumed = false + + val dialog = TimePickerDialog( + context, + { _, hour, minute -> + if (!resumed && cont.isActive) { + resumed = true + cont.resume(hour * 60 + minute) + } + }, + initialHour, + initialMinute, + is24Hour, + ) + + dialog.setOnCancelListener { + if (!resumed && cont.isActive) { + resumed = true + cont.resume(null) + } + } + dialog.setOnDismissListener { + if (!resumed && cont.isActive) { + resumed = true + cont.resume(null) + } + } + dialog.setButton(TimePickerDialog.BUTTON_NEGATIVE, context.getString(android.R.string.cancel)) { _, _ -> + if (!resumed && cont.isActive) { + resumed = true + cont.resume(null) + } + } + + dialog.show() + + cont.invokeOnCancellation { + dialog.dismiss() + } + } + + private fun showDayOptions(day: WeekDay) { + val schedule = weeklySchedule.get(day) + + val items = arrayOf( + context.getString(R.string.auto_switch_set_start_time), + context.getString(R.string.auto_switch_clear_start_time), + context.getString(R.string.auto_switch_set_stop_time), + context.getString(R.string.auto_switch_clear_stop_time), + ) + + MaterialAlertDialogBuilder(context) + .setTitle(day.displayName(context)) + .setItems(items) { _, which -> + when (which) { + 0 -> launch(Dispatchers.Main) { + val minutes = requestTime(schedule.startMinutes) + if (minutes != null) { + updateSchedule(day) { it.copy(startMinutes = minutes) } + } + } + + 1 -> updateSchedule(day) { it.copy(startMinutes = null) } + + 2 -> launch(Dispatchers.Main) { + val minutes = requestTime(schedule.stopMinutes) + if (minutes != null) { + updateSchedule(day) { it.copy(stopMinutes = minutes) } + } + } + + 3 -> updateSchedule(day) { it.copy(stopMinutes = null) } + } + } + .show() + } + + private fun updateSchedule( + day: WeekDay, + transform: (DailyAutoSwitchSchedule) -> DailyAutoSwitchSchedule, + ) { + launch(Dispatchers.Main) { + val updated = withContext(Dispatchers.IO) { + val schedule = serviceStore.autoSwitchWeeklySchedule + val newSchedule = schedule.update(day) { transform(it) } + serviceStore.autoSwitchWeeklySchedule = newSchedule + AutoSwitchReceiver.requestReschedule(context) + newSchedule + } + + weeklySchedule = updated + updateDaySummary(day) + } + } + + private fun formatTime(minutes: Int?): String { + if (minutes == null) { + return context.getString(R.string.auto_switch_time_not_set) + } + + val calendar = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, minutes / 60) + set(Calendar.MINUTE, minutes % 60) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + return DateFormat.getTimeFormat(context).format(calendar.time) + } + + private fun WeekDay.displayName(context: Context): String { + val names = DateFormatSymbols.getInstance().weekdays + return names.getOrNull(calendarValue) ?: name + } + + private fun PreferenceScreen.addCategory(text: Int): Preference { + val binding = PreferenceCategoryBinding.inflate(context.layoutInflater, root, false) + binding.textView.text = context.getString(text) + + val preference = object : Preference { + override val view: View + get() = binding.root + } + + addElement(preference) + return preference + } +} diff --git a/design/src/main/res/drawable/ic_baseline_schedule.xml b/design/src/main/res/drawable/ic_baseline_schedule.xml new file mode 100644 index 0000000000..db8574ae83 --- /dev/null +++ b/design/src/main/res/drawable/ic_baseline_schedule.xml @@ -0,0 +1,10 @@ + + + diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index d1eb2b75b4..dc7f0055a3 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -65,6 +65,21 @@ 设置 显示流量 在通知中自动刷新流量 + 定时开关 + 根据自定义计划自动启动或停止 Clash + 定时开关 + 策略 + 定时开关策略 + 关闭 + 按周计划 + 按周计划 + 日期 + 开启:%1$s • 关闭:%2$s + 未设置 + 设置开启时间 + 清除开启时间 + 设置关闭时间 + 清除关闭时间 允许 Clash 自动重启 自动重启 已停止 diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 9681f9f732..128b5bd640 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -235,6 +235,21 @@ Show Traffic Auto refresh traffic in notification + Auto Switch + Automatically start or stop Clash on a custom schedule + Auto Switch + Strategy + Auto switch strategy + Disabled + Weekly schedule + Weekly schedule + Day + Start: %1$s • Stop: %2$s + Not set + Set start time + Clear start time + Set stop time + Clear stop time Follow System (Android 10+) Always Dark diff --git a/service/src/main/AndroidManifest.xml b/service/src/main/AndroidManifest.xml index 167cddd595..6feb9e3ad7 100644 --- a/service/src/main/AndroidManifest.xml +++ b/service/src/main/AndroidManifest.xml @@ -85,5 +85,19 @@ + + + + + + + + + diff --git a/service/src/main/java/com/github/kr328/clash/service/AutoSwitchReceiver.kt b/service/src/main/java/com/github/kr328/clash/service/AutoSwitchReceiver.kt new file mode 100644 index 0000000000..e489abc0b0 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/AutoSwitchReceiver.kt @@ -0,0 +1,138 @@ +package com.github.kr328.clash.service + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.Build +import com.github.kr328.clash.common.compat.pendingIntentFlags +import com.github.kr328.clash.common.compat.startForegroundServiceCompat +import com.github.kr328.clash.common.constants.Intents +import com.github.kr328.clash.common.log.Log +import com.github.kr328.clash.common.util.intent +import com.github.kr328.clash.service.model.AutoSwitchAction +import com.github.kr328.clash.service.model.AutoSwitchStrategyType +import com.github.kr328.clash.service.model.ScheduledAutoSwitch +import com.github.kr328.clash.service.store.ServiceStore +import com.github.kr328.clash.service.util.sendBroadcastSelf +import java.util.TimeZone + +class AutoSwitchReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intents.ACTION_AUTO_SWITCH_RESCHEDULE, + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + Intent.ACTION_TIMEZONE_CHANGED, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIME_SET -> schedule(context) + + Intents.ACTION_AUTO_SWITCH_TRIGGER -> { + val actionName = intent.getStringExtra(EXTRA_ACTION) ?: return + val action = runCatching { AutoSwitchAction.valueOf(actionName) }.getOrNull() ?: return + + when (action) { + AutoSwitchAction.Start -> startClash(context) + AutoSwitchAction.Stop -> stopClash(context) + } + + schedule(context) + } + } + } + + companion object { + private const val EXTRA_ACTION = "action" + + fun requestReschedule(context: Context) { + val appContext = context.applicationContext + val intent = Intent(appContext, AutoSwitchReceiver::class.java).apply { + action = Intents.ACTION_AUTO_SWITCH_RESCHEDULE + } + appContext.sendBroadcastSelf(intent) + } + + private fun schedule(context: Context) { + val appContext = context.applicationContext + val store = ServiceStore(appContext) + val strategy = store.autoSwitchStrategy + + val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val cancelIntent = pendingIntent(appContext, null) + alarmManager.cancel(cancelIntent) + + if (strategy == AutoSwitchStrategyType.None) { + return + } + + val next = when (strategy) { + AutoSwitchStrategyType.None -> null + AutoSwitchStrategyType.Weekly -> store.autoSwitchWeeklySchedule.next( + System.currentTimeMillis(), + TimeZone.getDefault(), + ) + } + + if (next == null) { + return + } + + val triggerIntent = pendingIntent(appContext, next) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + next.triggerAtMillis, + triggerIntent, + ) + } else { + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + next.triggerAtMillis, + triggerIntent, + ) + } + } + + private fun startClash(context: Context) { + val appContext = context.applicationContext + if (useVpnMode(appContext)) { + val vpnIntent = VpnService.prepare(appContext) + if (vpnIntent != null) { + Log.w("AutoSwitch", "VPN permission required, skip auto start") + return + } + appContext.startForegroundServiceCompat(TunService::class.intent) + } else { + appContext.startForegroundServiceCompat(ClashService::class.intent) + } + } + + private fun stopClash(context: Context) { + context.applicationContext.sendBroadcastSelf(Intent(Intents.ACTION_CLASH_REQUEST_STOP)) + } + + private fun pendingIntent(context: Context, next: ScheduledAutoSwitch?): PendingIntent { + val intent = Intent(context, AutoSwitchReceiver::class.java).apply { + action = Intents.ACTION_AUTO_SWITCH_TRIGGER + if (next != null) { + putExtra(EXTRA_ACTION, next.action.name) + } + } + + return PendingIntent.getBroadcast( + context, + 0, + intent, + pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT), + ) + } + + private fun useVpnMode(context: Context): Boolean { + val preferences = context.getSharedPreferences("ui", Context.MODE_PRIVATE) + return preferences.getBoolean("enable_vpn", true) + } + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/model/AutoSwitch.kt b/service/src/main/java/com/github/kr328/clash/service/model/AutoSwitch.kt new file mode 100644 index 0000000000..0b92494ace --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/model/AutoSwitch.kt @@ -0,0 +1,117 @@ +package com.github.kr328.clash.service.model + +import kotlinx.serialization.Serializable +import java.util.Calendar +import java.util.EnumMap +import java.util.TimeZone + +@Serializable +enum class AutoSwitchStrategyType { + None, + Weekly, +} + +@Serializable +enum class WeekDay(val calendarValue: Int) { + Sunday(Calendar.SUNDAY), + Monday(Calendar.MONDAY), + Tuesday(Calendar.TUESDAY), + Wednesday(Calendar.WEDNESDAY), + Thursday(Calendar.THURSDAY), + Friday(Calendar.FRIDAY), + Saturday(Calendar.SATURDAY); + + companion object { + fun fromCalendar(value: Int): WeekDay { + return values().firstOrNull { it.calendarValue == value } ?: Sunday + } + } +} + +@Serializable +data class DailyAutoSwitchSchedule( + val startMinutes: Int? = null, + val stopMinutes: Int? = null, +) + +@Serializable +data class WeeklyAutoSwitchSchedule( + val entries: Map = defaultEntries(), +) { + fun get(day: WeekDay): DailyAutoSwitchSchedule { + return entries[day] ?: DailyAutoSwitchSchedule() + } + + fun update( + day: WeekDay, + transform: (DailyAutoSwitchSchedule) -> DailyAutoSwitchSchedule, + ): WeeklyAutoSwitchSchedule { + val map = EnumMap(WeekDay::class.java) + map.putAll(entries) + map[day] = transform(map[day] ?: DailyAutoSwitchSchedule()) + return WeeklyAutoSwitchSchedule(map) + } + + fun next(nowMillis: Long, zone: TimeZone): ScheduledAutoSwitch? { + var best: ScheduledAutoSwitch? = null + + for (offset in 0..7) { + val dayCalendar = Calendar.getInstance(zone).apply { + timeInMillis = nowMillis + add(Calendar.DAY_OF_YEAR, offset) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + val day = WeekDay.fromCalendar(dayCalendar.get(Calendar.DAY_OF_WEEK)) + val schedule = get(day) + + schedule.startMinutes?.let { minutes -> + val triggerAt = triggerTime(dayCalendar, minutes) + if (triggerAt > nowMillis) { + val candidate = ScheduledAutoSwitch(triggerAt, AutoSwitchAction.Start) + best = best?.takeIf { it.triggerAtMillis <= candidate.triggerAtMillis } ?: candidate + } + } + + schedule.stopMinutes?.let { minutes -> + val triggerAt = triggerTime(dayCalendar, minutes) + if (triggerAt > nowMillis) { + val candidate = ScheduledAutoSwitch(triggerAt, AutoSwitchAction.Stop) + best = best?.takeIf { it.triggerAtMillis <= candidate.triggerAtMillis } ?: candidate + } + } + } + + return best + } + + companion object { + private fun defaultEntries(): Map { + val map = EnumMap(WeekDay::class.java) + WeekDay.values().forEach { map[it] = DailyAutoSwitchSchedule() } + return map + } + + private fun triggerTime(base: Calendar, minutes: Int): Long { + val calendar = base.clone() as Calendar + calendar.set(Calendar.HOUR_OF_DAY, minutes / 60) + calendar.set(Calendar.MINUTE, minutes % 60) + calendar.set(Calendar.SECOND, 0) + calendar.set(Calendar.MILLISECOND, 0) + return calendar.timeInMillis + } + } +} + +data class ScheduledAutoSwitch( + val triggerAtMillis: Long, + val action: AutoSwitchAction, +) + +enum class AutoSwitchAction { + Start, + Stop, +} diff --git a/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt b/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt index d361848ff5..7e8073833b 100644 --- a/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt +++ b/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt @@ -5,7 +5,12 @@ import com.github.kr328.clash.common.store.Store import com.github.kr328.clash.common.store.asStoreProvider import com.github.kr328.clash.service.PreferenceProvider import com.github.kr328.clash.service.model.AccessControlMode +import com.github.kr328.clash.service.model.AutoSwitchStrategyType +import com.github.kr328.clash.service.model.WeeklyAutoSwitchSchedule import java.util.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.decodeFromString class ServiceStore(context: Context) { private val store = Store( @@ -65,4 +70,34 @@ class ServiceStore(context: Context) { key = "dynamic_notification", defaultValue = true ) -} \ No newline at end of file + + var autoSwitchStrategy: AutoSwitchStrategyType by store.enum( + key = "auto_switch_strategy", + defaultValue = AutoSwitchStrategyType.None, + values = AutoSwitchStrategyType.values(), + ) + + private var autoSwitchWeeklyScheduleRaw: WeeklyAutoSwitchSchedule? by store.typedString( + key = "auto_switch_weekly_schedule", + from = { + if (it.isBlank()) { + null + } else { + runCatching { json.decodeFromString(it) }.getOrNull() + } + }, + to = { schedule -> + schedule?.let { json.encodeToString(it) } ?: "" + }, + ) + + var autoSwitchWeeklySchedule: WeeklyAutoSwitchSchedule + get() = autoSwitchWeeklyScheduleRaw ?: WeeklyAutoSwitchSchedule() + set(value) { + autoSwitchWeeklyScheduleRaw = value + } + + companion object { + private val json = Json { ignoreUnknownKeys = true } + } +}