From 548e58155461e1cd7b1b95f922d691c22dcc9f7b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 22:13:55 +0200 Subject: [PATCH 1/2] feat(backup): automatic periodic .ics export of local calendars (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduled one-way export of local calendars to a user-chosen folder, overwriting calendula-backup.ics each run — the manual backup, automated. Not a sync; stays INTERNET-free (provider reads + local file write). - WorkManager periodic job (deps: work-runtime-ktx, documentfile); worker pulls collaborators via a Hilt @EntryPoint, so no custom WorkerFactory wiring. First periodic run is delayed one interval so it can't race the immediate "run now" feedback run; the writer also overwrites the canonical file and cleans up any "(1)" duplicates from earlier races. - SettingsPrefs: enabled, interval (minutes, floored at 30), folder Uri, last-run status; persisted SAF write grant. - UI in Calendars > Backup: toggle, folder picker, amount+unit interval dialog, last-run status line. Notifies after repeated failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 3 + .../calendula/data/backup/AutoBackup.kt | 170 ++++++++++++++++++ .../calendula/data/ics/IcsExporter.kt | 32 ++++ .../calendula/data/prefs/SettingsPrefs.kt | 65 +++++++ .../calendula/ui/calendars/CalendarsScreen.kt | 152 ++++++++++++++++ .../ui/calendars/CalendarsViewModel.kt | 77 ++++++++ app/src/main/res/values/strings.xml | 28 +++ gradle/libs.versions.toml | 4 + 8 files changed, 531 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c9966c..46440cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -152,6 +152,9 @@ dependencies { implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt new file mode 100644 index 0000000..c08fa72 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt @@ -0,0 +1,170 @@ +package de.jeanlucmakiola.calendula.data.backup + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.ics.IcsWriter +import kotlinx.coroutines.flow.first +import java.util.concurrent.TimeUnit +import kotlin.time.Clock + +/** + * Schedules and runs the automatic periodic `.ics` backup of local calendars + * (issue #8). One-way export only — it mirrors the manual "Back up" into a + * user-chosen folder on an interval; it is not a sync. Stays INTERNET-free: + * everything is local provider reads + a local file write. + */ +object BackupScheduler { + + private const val WORK_NAME = "auto-backup" + private const val WORK_NAME_NOW = "auto-backup-now" + + /** + * Reconcile the scheduled work with the current settings: enqueue a unique + * periodic request when backup is on and a folder is set, otherwise cancel + * it. WorkManager persists the request across reboots on its own. + * + * The first run is delayed by one interval so it never overlaps an immediate + * [runNow] (two simultaneous writes would race and leave a "(1)" duplicate). + */ + fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) { + val workManager = WorkManager.getInstance(context) + if (!enabled || !hasFolder) { + workManager.cancelUniqueWork(WORK_NAME) + return + } + // WorkManager's own floor is 15 min; the UI floors the user choice at 30. + val interval = intervalMinutes.coerceAtLeast(15L) + val request = PeriodicWorkRequestBuilder(interval, TimeUnit.MINUTES) + .setInitialDelay(interval, TimeUnit.MINUTES) + .build() + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } + + /** + * Run one export immediately (e.g. right after enabling or changing the + * folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into + * a single run rather than racing each other. + */ + fun runNow(context: Context) { + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME_NOW, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().build(), + ) + } +} + +/** + * Exports the local calendars to `calendula-backup.ics` in the configured folder, + * overwriting the previous file. Pulls its collaborators through a Hilt + * [EntryPoint] so it works under WorkManager's default (no-arg) worker factory — + * no custom factory / Application wiring needed. Records the outcome for the + * settings status line, and notifies after repeated failures. + */ +class BackupWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Deps { + fun settingsPrefs(): SettingsPrefs + fun repository(): CalendarRepository + fun exporter(): IcsExporter + } + + override suspend fun doWork(): Result { + val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java) + val prefs = deps.settingsPrefs() + val folder = prefs.autoBackupFolderUri.first() + ?: return Result.failure() // nothing to write to — leave scheduling to settings + val now = System.currentTimeMillis() + return try { + val events = deps.repository().exportEvents() + val content = IcsWriter().writeCalendar(events, Clock.System.now()) + deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content) + prefs.recordAutoBackupRun(success = true, atMillis = now) + Result.success() + } catch (e: Exception) { + prefs.recordAutoBackupRun(success = false, atMillis = now) + if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) { + notifyFailure(applicationContext) + } + Log.w(TAG, "Automatic backup failed", e) + Result.retry() + } + } + + private fun notifyFailure(context: Context) { + val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + if (!canPost) return + val manager = NotificationManagerCompat.from(context) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.backup_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { description = context.getString(R.string.backup_channel_description) }, + ) + val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) + val tap = launch?.let { + PendingIntent.getActivity( + context, 0, it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.backup_failed_title)) + .setContentText(context.getString(R.string.backup_failed_text)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .apply { tap?.let(::setContentIntent) } + .build() + try { + manager.notify(NOTIFICATION_ID, notification) + } catch (e: SecurityException) { + Log.w(TAG, "Could not post backup-failure notification", e) + } + } + + companion object { + const val BACKUP_FILE_NAME = "calendula-backup.ics" + private const val FAILURE_NOTIFY_THRESHOLD = 2 + private const val CHANNEL_ID = "backup" + private const val NOTIFICATION_ID = 2 + private const val TAG = "BackupWorker" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt index 70b0f9a..5ad0967 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics import android.content.Context import android.net.Uri import androidx.core.content.FileProvider +import androidx.documentfile.provider.DocumentFile import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.IOException @@ -28,6 +29,36 @@ class IcsExporter @Inject constructor( } ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})") } + /** + * Write [content] to [fileName] inside the persisted SAF tree [folder], + * overwriting it if it already exists (the automatic-backup destination). + * Requires a persisted write grant on [folder]. Throws on failure. + */ + fun writeToFolder(folder: Uri, fileName: String, content: String) { + val dir = DocumentFile.fromTreeUri(context, folder) + ?: throw IOException("Backup folder is not accessible") + if (!dir.exists() || !dir.canWrite()) { + throw IOException("Backup folder is missing or not writable") + } + // Reuse the canonical file if present and clean up any "name (1).ics" + // duplicates SAF may have created if two runs ever raced — so we always + // converge on a single overwritten file. + val base = fileName.substringBeforeLast('.') + val ext = fileName.substringAfterLast('.', "") + var target: DocumentFile? = null + for (child in dir.listFiles()) { + val name = child.name ?: continue + when { + name == fileName -> target = child + name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete() + } + } + val file = target + ?: dir.createFile(MIME_CALENDAR, fileName) + ?: throw IOException("Could not create backup file in the chosen folder") + writeDocument(file.uri, content) + } + /** * Stage [content] in a private cache file and return a shareable content * Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to @@ -42,5 +73,6 @@ class IcsExporter @Inject constructor( private companion object { const val SHARE_DIR = "shared_ics" + const val MIME_CALENDAR = "text/calendar" } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index ef2c757..0fec684 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange @@ -327,6 +328,52 @@ class SettingsPrefs @Inject constructor( } } + // --- Automatic backup (issue #8) ------------------------------------ + + /** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */ + val autoBackupEnabled: Flow = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false } + + suspend fun setAutoBackupEnabled(enabled: Boolean) { + store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled } + } + + /** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */ + val autoBackupIntervalMinutes: Flow = store.data.map { prefs -> + (prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL) + } + + suspend fun setAutoBackupIntervalMinutes(minutes: Long) { + store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) } + } + + /** Persisted SAF tree Uri of the destination folder, or null if unset. */ + val autoBackupFolderUri: Flow = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] } + + suspend fun setAutoBackupFolderUri(uri: String?) { + store.edit { prefs -> + if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri + } + } + + /** Outcome of the last automatic run, for the settings status line. */ + val autoBackupStatus: Flow = store.data.map { prefs -> + BackupStatus( + lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L, + lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true, + consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0, + ) + } + + /** Record an automatic run's outcome; resets the failure streak on success. */ + suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) { + store.edit { prefs -> + prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis + prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success + val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0 + prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1 + } + } + private fun parseFormFields(stored: String?): Set = when (stored) { null -> DEFAULT_FORM_FIELDS else -> stored.split(',') @@ -366,9 +413,27 @@ class SettingsPrefs @Inject constructor( stringPreferencesKey("per_calendar_allday_reminder_override") internal val DEFAULT_FORM_FIELDS = setOf(EventFormField.Location, EventFormField.Description) + internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled") + internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes") + internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri") + internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run") + internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success") + internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures") + /** Default automatic-backup interval: daily. */ + const val DEFAULT_BACKUP_INTERVAL = 1_440L + /** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */ + const val MIN_BACKUP_INTERVAL = 30L } } +/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */ +data class BackupStatus( + /** Epoch millis of the last run, or 0 if it has never run. */ + val lastRun: Long, + val lastSuccess: Boolean, + val consecutiveFailures: Int, +) + /** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */ sealed interface CalendarReminderOverride { /** No override — the calendar uses the global default. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 6ae8fba..eeb4086 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -4,6 +4,7 @@ import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.provider.Settings +import android.text.format.DateUtils import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog @@ -76,18 +78,26 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.BackupStatus +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.DialogAmountField +import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.predictiveBack @@ -118,6 +128,7 @@ fun CalendarsScreen( val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() + val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() // null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar. // [editorSession] bumps on every open so the editor's field state resets for @@ -158,6 +169,10 @@ fun CalendarsScreen( backupResult = backupResult, onExportBackup = viewModel::exportBackup, onConsumeBackupResult = viewModel::consumeBackupResult, + autoBackup = autoBackup, + onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled, + onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes, + onSetAutoBackupFolder = viewModel::setAutoBackupFolder, onBack = onBack, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, @@ -177,6 +192,10 @@ private fun CalendarsList( backupResult: BackupResult?, onExportBackup: (android.net.Uri) -> Unit, onConsumeBackupResult: () -> Unit, + autoBackup: AutoBackupUiState, + onSetAutoBackupEnabled: (Boolean) -> Unit, + onSetAutoBackupInterval: (Long) -> Unit, + onSetAutoBackupFolder: (android.net.Uri) -> Unit, onBack: () -> Unit, onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, @@ -204,6 +223,13 @@ private fun CalendarsList( contract = ActivityResultContracts.CreateDocument("text/calendar"), ) { uri -> uri?.let(onExportBackup) } + // SAF folder picker for the automatic-backup destination; the VM persists the + // write grant so background runs can keep writing to it. + val pickFolder = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> uri?.let(onSetAutoBackupFolder) } + var showInterval by remember { mutableStateOf(false) } + val backupFailedText = stringResource(R.string.calendars_backup_failed) LaunchedEffect(backupResult) { when (val r = backupResult) { @@ -288,6 +314,35 @@ private fun CalendarsList( runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } }, ) + + // Automatic periodic export to a chosen folder (issue #8). + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.calendars_auto_backup), + summary = stringResource(R.string.calendars_auto_backup_hint), + position = if (autoBackup.enabled) Position.Top else Position.Alone, + leading = { LeadingAvatar(Icons.Default.Schedule) }, + trailing = { + Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled) + }, + onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) }, + ) + if (autoBackup.enabled) { + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_folder), + summary = rememberFolderName(autoBackup.folderUri) + ?: stringResource(R.string.calendars_auto_backup_folder_unset), + position = Position.Middle, + onClick = { runCatching { pickFolder.launch(null) } }, + ) + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_interval), + summary = backupIntervalLabel(autoBackup.intervalMinutes), + position = Position.Bottom, + onClick = { showInterval = true }, + ) + HintText(backupStatusText(autoBackup.status)) + } } Spacer(Modifier.height(16.dp)) @@ -346,6 +401,14 @@ private fun CalendarsList( } } } + + if (showInterval) { + BackupIntervalDialog( + currentMinutes = autoBackup.intervalMinutes, + onConfirm = onSetAutoBackupInterval, + onDismiss = { showInterval = false }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -760,6 +823,95 @@ private fun HintText(text: String) { ) } +/** Readable name of the persisted backup folder, resolved from its tree Uri. */ +@Composable +private fun rememberFolderName(uriString: String?): String? { + val context = LocalContext.current + return remember(uriString) { + uriString?.let { + runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull() + } + } +} + +/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */ +@Composable +private fun backupIntervalLabel(minutes: Long): String { + val duration = when { + minutes % MINUTES_PER_WEEK == 0L -> + pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt()) + minutes % MINUTES_PER_DAY == 0L -> + pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt()) + minutes % 60L == 0L -> + pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt()) + else -> + pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt()) + } + return stringResource(R.string.calendars_auto_backup_every, duration) +} + +/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */ +@Composable +private fun backupStatusText(status: BackupStatus): String { + if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never) + val relative = DateUtils.getRelativeTimeSpanString( + status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, + ).toString() + return if (status.lastSuccess) { + stringResource(R.string.calendars_auto_backup_status_ok, relative) + } else { + stringResource(R.string.calendars_auto_backup_status_failed, relative) + } +} + +/** Amount + unit picker for the backup interval (floored at 30 minutes). */ +@Composable +private fun BackupIntervalDialog( + currentMinutes: Long, + onConfirm: (Long) -> Unit, + onDismiss: () -> Unit, +) { + // minutes-per-unit for each entry; pick the largest unit the current value divides into. + val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) } + val units = stringArrayResource(R.array.backup_interval_units).toList() + val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0) + var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) } + var unitIndex by rememberSaveable { mutableStateOf(initialUnit) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.calendars_auto_backup_interval)) }, + text = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1") + Spacer(Modifier.width(12.dp)) + DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it } + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.calendars_auto_backup_interval_min), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton(onClick = { + val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L + onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL)) + onDismiss() + }) { Text(stringResource(R.string.reminder_custom_set)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +private const val MINUTES_PER_DAY = 1_440L +private const val MINUTES_PER_WEEK = 10_080L + /** * Pick the app to open for managing a synced calendar's account. The account's * own authenticator package (resolved from [AccountManager], no permission diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 2d1e1b8..d56ee9a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -1,13 +1,19 @@ package de.jeanlucmakiola.calendula.ui.calendars +import android.content.Context +import android.content.Intent import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher @@ -16,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn @@ -33,9 +40,11 @@ import javax.inject.Inject */ @HiltViewModel class CalendarsViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val repository: CalendarRepository, private val icsExporter: IcsExporter, private val prefs: CalendarPrefs, + private val settingsPrefs: SettingsPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -63,6 +72,22 @@ class CalendarsViewModel @Inject constructor( initialValue = emptySet(), ) + /** Automatic-backup settings + last-run status, for the Backup section UI. */ + val autoBackup: StateFlow = combine( + settingsPrefs.autoBackupEnabled, + settingsPrefs.autoBackupIntervalMinutes, + settingsPrefs.autoBackupFolderUri, + settingsPrefs.autoBackupStatus, + ) { enabled, interval, folder, status -> + AutoBackupUiState(enabled, interval, folder, status) + } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = AutoBackupUiState(), + ) + private val _error = MutableStateFlow(false) val error: StateFlow = _error.asStateFlow() @@ -136,6 +161,50 @@ class CalendarsViewModel @Inject constructor( } } + // --- Automatic backup (issue #8) ------------------------------------ + + fun setAutoBackupEnabled(enabled: Boolean) { + viewModelScope.launch { + settingsPrefs.setAutoBackupEnabled(enabled) + reschedule() + // Give immediate feedback when turning it on with a folder already set. + if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) { + BackupScheduler.runNow(context) + } + } + } + + fun setAutoBackupIntervalMinutes(minutes: Long) { + viewModelScope.launch { + settingsPrefs.setAutoBackupIntervalMinutes(minutes) + reschedule() + } + } + + /** Persist the chosen destination folder (taking a durable write grant) and run once. */ + fun setAutoBackupFolder(uri: Uri) { + viewModelScope.launch { + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + settingsPrefs.setAutoBackupFolderUri(uri.toString()) + reschedule() + if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context) + } + } + + private suspend fun reschedule() { + BackupScheduler.apply( + context = context, + enabled = settingsPrefs.autoBackupEnabled.first(), + intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(), + hasFolder = settingsPrefs.autoBackupFolderUri.first() != null, + ) + } + private inline fun write(crossinline block: suspend () -> Unit) { viewModelScope.launch { try { @@ -149,6 +218,14 @@ class CalendarsViewModel @Inject constructor( } } +/** Automatic-backup settings + last-run status for the Backup section. */ +data class AutoBackupUiState( + val enabled: Boolean = false, + val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL, + val folderUri: String? = null, + val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0), +) + /** Outcome of a whole-calendar backup, surfaced once to the screen. */ sealed interface BackupResult { data class Success(val eventCount: Int) : BackupResult diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdc17d3..4a2331a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -197,6 +197,14 @@ %d hour %d hours + + %d day + %d days + + + %d week + %d weeks + (No title) @@ -363,6 +371,26 @@ Backup Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy. Export as .ics file + Automatic backup + Periodically export your local calendars to a folder as an .ics file. + Backup folder + Tap to choose a folder + Interval + Every %1$s + Minimum 30 minutes. + No automatic backup yet + Last backup: %1$s + Last backup failed: %1$s + Backup + Warns if automatic backups fail repeatedly. + Automatic backup failed + Calendula couldn\'t write the backup file. Check the backup folder in Settings. + + Minutes + Hours + Days + Weeks + Couldn\'t export the backup. Exported %d event. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0aee61f..6b13d4f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,10 +26,14 @@ lifecycleCompose = "2.10.0" androidxTestRules = "1.7.0" # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). glance = "1.1.1" +work = "2.10.1" +documentfile = "1.0.1" [libraries] # AndroidX core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } From 1424f0ffc054dfbbde5ba34c78615fe02aa9bc84 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 22:17:24 +0200 Subject: [PATCH 2/2] feat(backup): connect one-time export + automatic backup into one card Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/calendars/CalendarsScreen.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index eeb4086..4900a71 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -306,21 +306,20 @@ private fun CalendarsList( Spacer(Modifier.height(16.dp)) SectionHeader(stringResource(R.string.calendars_backup_header)) HintText(stringResource(R.string.calendars_backup_hint)) + // One connected card: the one-time export on top, then automatic + // backup (and its folder/interval rows when on). GroupedRow( title = stringResource(R.string.calendars_backup_action), - position = Position.Alone, + position = Position.Top, leading = { LeadingAvatar(Icons.Default.FileDownload) }, onClick = { runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } }, ) - - // Automatic periodic export to a chosen folder (issue #8). - Spacer(Modifier.height(16.dp)) GroupedRow( title = stringResource(R.string.calendars_auto_backup), summary = stringResource(R.string.calendars_auto_backup_hint), - position = if (autoBackup.enabled) Position.Top else Position.Alone, + position = if (autoBackup.enabled) Position.Middle else Position.Bottom, leading = { LeadingAvatar(Icons.Default.Schedule) }, trailing = { Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)