Give Backup & restore its loading and failure states (#304)
The screen derived its rows straight from the calendar list, which starts empty, catches to empty on a provider error, and can legitimately load with nothing exportable and nothing importable. All three rendered as a blank page with no way to import anything. Adds BackupUiState (Loading / Failure / Ready) and keeps the restore row on screen whenever the screen has anything at all to show: when no calendar can receive events it says so and routes to the calendar manager instead of disappearing. New FailureReason.NoImportTarget covers the case the list itself can't explain -- calendars exist and are visible, but every one is read-only, managed or not synced. Export stays available on an all-hidden device: it reads the provider directly, so calling that a failure would hide a working action. Also adds an import test against a Google Calendar export dialect (VTIMEZONE, TZID-qualified DTSTART/EXDATE, folded lines, RECURRENCE-ID overrides) -- the parser already handles all of it.
This commit is contained in:
@@ -253,6 +253,13 @@ enum class FailureReason {
|
|||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
AllCalendarsHidden,
|
AllCalendarsHidden,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars exist and are switched on, but none can receive an event: every
|
||||||
|
* one is read-only, app-managed, or not synced to this device. Distinct from
|
||||||
|
* [AllCalendarsHidden], which a visibility switch fixes.
|
||||||
|
*/
|
||||||
|
NoImportTarget,
|
||||||
ProviderUnavailable,
|
ProviderUnavailable,
|
||||||
EventNotFound,
|
EventNotFound,
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|||||||
@@ -550,18 +550,24 @@ fun CalendarHost(
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
BackupScreen(
|
// The manager sits directly under this screen, so its failure states'
|
||||||
onBack = { showBackup = false },
|
// "Manage calendars" way out is simply popping back to it (#304).
|
||||||
// Restore runs the normal .ics import, and both this screen and
|
CompositionLocalProvider(
|
||||||
// the manager that can have opened it are declared above the
|
LocalManageCalendars provides { showBackup = false },
|
||||||
// import overlays — so both have to step aside.
|
) {
|
||||||
onImport = {
|
BackupScreen(
|
||||||
importUri = it
|
onBack = { showBackup = false },
|
||||||
importForceMany = true
|
// Restore runs the normal .ics import, and both this screen
|
||||||
showBackup = false
|
// and the manager that can have opened it are declared above
|
||||||
showCalendars = false
|
// the import overlays — so both have to step aside.
|
||||||
},
|
onImport = {
|
||||||
)
|
importUri = it
|
||||||
|
importForceMany = true
|
||||||
|
showBackup = false
|
||||||
|
showCalendars = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.net.Uri
|
|||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -18,6 +19,7 @@ import androidx.compose.material.icons.filled.Schedule
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@@ -47,9 +49,10 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
@@ -80,18 +83,13 @@ fun BackupScreen(
|
|||||||
onImport: (Uri) -> Unit,
|
onImport: (Uri) -> Unit,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Export covers local calendars only; managed special-dates mirrors are
|
|
||||||
// rebuilt from contacts. Restore can target anything the import picker offers.
|
|
||||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
|
||||||
val canImport = calendars.any { it.isEventTarget }
|
|
||||||
|
|
||||||
// Exports everything eligible (null); the per-calendar selector owns its
|
// Exports everything eligible (null); the per-calendar selector owns its
|
||||||
// own launcher.
|
// own launcher.
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
@@ -135,75 +133,35 @@ fun BackupScreen(
|
|||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
when (val s = state) {
|
||||||
|
BackupUiState.Loading -> BackupLoading()
|
||||||
if (exportable.isNotEmpty()) {
|
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
||||||
GroupedRow(
|
is BackupUiState.Ready -> BackupContent(
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
exportable = s.exportable,
|
||||||
position = Position.Top,
|
canImport = s.canImport,
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
autoBackup = autoBackup,
|
||||||
onClick = {
|
viewModel = viewModel,
|
||||||
// A single exportable calendar skips the selector.
|
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
if (exportable.size == 1) {
|
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
onExportAll = {
|
||||||
} else {
|
runCatching { createBackup.launch(defaultBackupName()) }
|
||||||
showExportPicker = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
onExportPick = { showExportPicker = true },
|
||||||
GroupedRow(
|
onEditInterval = { showInterval = true },
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
summary = stringResource(R.string.calendars_restore_hint),
|
|
||||||
position = Position.Middle,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup),
|
|
||||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
|
||||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
|
||||||
trailing = {
|
|
||||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setAutoBackupEnabled(!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))
|
|
||||||
}
|
|
||||||
} else if (canImport) {
|
|
||||||
// Nothing to back up, but restore is still possible — don't hide
|
|
||||||
// it behind export eligibility.
|
|
||||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
|
||||||
HintText(stringResource(R.string.calendars_restore_hint))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showExportPicker) {
|
// Gated on Ready rather than resetting the flag when the state leaves it:
|
||||||
ExportCalendarPicker(
|
// flipping it here would be a write during composition.
|
||||||
calendars = exportable,
|
(state as? BackupUiState.Ready)?.let { ready ->
|
||||||
onExport = viewModel::exportBackup,
|
if (showExportPicker) {
|
||||||
onDismiss = { showExportPicker = false },
|
ExportCalendarPicker(
|
||||||
)
|
calendars = ready.exportable,
|
||||||
|
onExport = viewModel::exportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (showInterval) {
|
if (showInterval) {
|
||||||
BackupIntervalDialog(
|
BackupIntervalDialog(
|
||||||
@@ -214,6 +172,105 @@ fun BackupScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The screen's one-line loading state, in the scaffold's content column. */
|
||||||
|
@Composable
|
||||||
|
private fun BackupLoading() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 48.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default file name for a one-shot export. */
|
||||||
|
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The working screen: export (when there are local calendars to export), restore,
|
||||||
|
* and the automatic-backup block. Restore is always on screen — when nothing can
|
||||||
|
* receive the events it says so and routes to the calendar manager rather than
|
||||||
|
* disappearing, which is what #304 reported as an invisible button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BackupContent(
|
||||||
|
exportable: List<CalendarSource>,
|
||||||
|
canImport: Boolean,
|
||||||
|
autoBackup: AutoBackupUiState,
|
||||||
|
viewModel: CalendarsViewModel,
|
||||||
|
onRestore: () -> Unit,
|
||||||
|
onPickFolder: () -> Unit,
|
||||||
|
onExportAll: () -> Unit,
|
||||||
|
onExportPick: () -> Unit,
|
||||||
|
onEditInterval: () -> Unit,
|
||||||
|
) {
|
||||||
|
val manageCalendars = LocalManageCalendars.current
|
||||||
|
// Restore never depends on export eligibility, and never disappears: when no
|
||||||
|
// calendar can receive events it explains that and offers the way to fix it.
|
||||||
|
val restoreSummary = if (canImport) {
|
||||||
|
stringResource(R.string.calendars_restore_hint)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_restore_unavailable)
|
||||||
|
}
|
||||||
|
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
||||||
|
|
||||||
|
if (exportable.isEmpty()) {
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(restoreSummary)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = onRestoreClick,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Top,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
// A single exportable calendar skips the selector.
|
||||||
|
onClick = if (exportable.size == 1) onExportAll else onExportPick,
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = restoreSummary,
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = onRestoreClick,
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutoBackupEnabled(!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 = onPickFolder,
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = onEditInterval,
|
||||||
|
)
|
||||||
|
HintText(backupStatusText(autoBackup.status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of the Backup & restore screen (#69). Three states, because the calendar
|
||||||
|
* list it is derived from has all three: it arrives empty, it can throw, and
|
||||||
|
* "loaded but nothing to offer" is a real outcome the screen used to render as a
|
||||||
|
* blank page (#304).
|
||||||
|
*/
|
||||||
|
sealed interface BackupUiState {
|
||||||
|
data object Loading : BackupUiState
|
||||||
|
data class Failure(val reason: FailureReason) : BackupUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At least one half of the screen works. [exportable] may be empty (restore
|
||||||
|
* only) and [canImport] may be false (export only), but never both — that is
|
||||||
|
* a [Failure].
|
||||||
|
*/
|
||||||
|
data class Ready(
|
||||||
|
val exportable: List<CalendarSource>,
|
||||||
|
val canImport: Boolean,
|
||||||
|
) : BackupUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the screen can offer for this calendar list.
|
||||||
|
*
|
||||||
|
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||||
|
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||||
|
* import picker offers ([isEventTarget]).
|
||||||
|
*
|
||||||
|
* A failure is raised only when *neither* is possible — deliberately not
|
||||||
|
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||||
|
* failure while its local calendars are still perfectly exportable. When both
|
||||||
|
* halves are dead the list itself usually says why (no calendars, everything
|
||||||
|
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
||||||
|
* calendars exist and are visible but every one of them is read-only, managed or
|
||||||
|
* not synced to the device.
|
||||||
|
*/
|
||||||
|
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||||
|
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val canImport = calendars.any { it.isEventTarget }
|
||||||
|
if (exportable.isEmpty() && !canImport) {
|
||||||
|
return BackupUiState.Failure(
|
||||||
|
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Backup & restore screen's own view of that list, with the loading and
|
||||||
|
* failure states [calendars] flattens away — it starts empty and catches to
|
||||||
|
* empty, which that screen used to render as a blank page (#304).
|
||||||
|
*/
|
||||||
|
val backupState: StateFlow<BackupUiState> =
|
||||||
|
repository.calendars()
|
||||||
|
.map { backupUiState(it) }
|
||||||
|
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = BackupUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
settingsPrefs.autoBackupEnabled,
|
settingsPrefs.autoBackupEnabled,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
|||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
||||||
|
FailureReason.NoImportTarget -> R.string.state_failure_no_import_target
|
||||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
||||||
FailureReason.Unknown,
|
FailureReason.Unknown,
|
||||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
||||||
@@ -43,6 +44,7 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
|||||||
val actionRes = when (reason) {
|
val actionRes = when (reason) {
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
||||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden_action
|
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden_action
|
||||||
|
FailureReason.NoImportTarget -> R.string.state_failure_all_hidden_action
|
||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||||
else -> R.string.state_retry
|
else -> R.string.state_retry
|
||||||
}
|
}
|
||||||
@@ -50,7 +52,9 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
|||||||
FailureReason.NoCalendarsConfigured -> {
|
FailureReason.NoCalendarsConfigured -> {
|
||||||
{ context.startCalendarSetup() }
|
{ context.startCalendarSetup() }
|
||||||
}
|
}
|
||||||
FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry
|
FailureReason.AllCalendarsHidden,
|
||||||
|
FailureReason.NoImportTarget,
|
||||||
|
-> manageCalendars ?: onRetry
|
||||||
else -> onRetry
|
else -> onRetry
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
||||||
<string name="state_failure_all_hidden">All your calendars are switched off.</string>
|
<string name="state_failure_all_hidden">All your calendars are switched off.</string>
|
||||||
<string name="state_failure_all_hidden_action">Manage calendars</string>
|
<string name="state_failure_all_hidden_action">Manage calendars</string>
|
||||||
|
<string name="state_failure_no_import_target">None of your calendars can take new events. They are read-only, managed by another app, or not synced to this device.</string>
|
||||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||||
|
|
||||||
<!-- Long-press a field to copy it (#195) -->
|
<!-- Long-press a field to copy it (#195) -->
|
||||||
@@ -679,6 +680,7 @@
|
|||||||
<string name="calendars_restore_header">Restore</string>
|
<string name="calendars_restore_header">Restore</string>
|
||||||
<string name="calendars_restore_action">Restore from .ics file</string>
|
<string name="calendars_restore_action">Restore from .ics file</string>
|
||||||
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
|
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
|
||||||
|
<string name="calendars_restore_unavailable">No calendar can take the imported events yet.</string>
|
||||||
<string name="calendars_auto_backup">Automatic backup</string>
|
<string name="calendars_auto_backup">Automatic backup</string>
|
||||||
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ class ModelsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `FailureReason enum has all six variants`() {
|
fun `FailureReason enum has all seven variants`() {
|
||||||
assertThat(FailureReason.values().toSet()).isEqualTo(
|
assertThat(FailureReason.values().toSet()).isEqualTo(
|
||||||
setOf(
|
setOf(
|
||||||
FailureReason.PermissionRevoked,
|
FailureReason.PermissionRevoked,
|
||||||
FailureReason.NoCalendarsConfigured,
|
FailureReason.NoCalendarsConfigured,
|
||||||
FailureReason.AllCalendarsHidden,
|
FailureReason.AllCalendarsHidden,
|
||||||
|
FailureReason.NoImportTarget,
|
||||||
FailureReason.ProviderUnavailable,
|
FailureReason.ProviderUnavailable,
|
||||||
FailureReason.EventNotFound,
|
FailureReason.EventNotFound,
|
||||||
FailureReason.Unknown,
|
FailureReason.Unknown,
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading a Google Calendar export (Codeberg #304), whose dialect the parser had
|
||||||
|
* never been held to: `VTIMEZONE` blocks with `X-LIC-LOCATION`, `TZID`-qualified
|
||||||
|
* `DTSTART`/`EXDATE`, folded `DESCRIPTION` and `ATTENDEE` lines, `RECURRENCE-ID`
|
||||||
|
* overrides written as separate `VEVENT`s, and `@google.com` UIDs.
|
||||||
|
*
|
||||||
|
* The fixture is a trimmed copy of what "Settings → Import & export → Export"
|
||||||
|
* produces, CRLF and all. Note that Google hands that export out as a **zip**
|
||||||
|
* containing one `.ics` per calendar — the file here is one member of it.
|
||||||
|
*/
|
||||||
|
class IcsGoogleImportTest {
|
||||||
|
|
||||||
|
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
|
||||||
|
|
||||||
|
private val result: IcsParseResult = parser.parse(
|
||||||
|
checkNotNull(
|
||||||
|
javaClass.classLoader?.getResourceAsStream("ics/google-calendar-export.ics"),
|
||||||
|
).use { it.readBytes().toString(Charsets.UTF_8) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun event(summary: String) = result.events.single { it.summary == summary }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every master event imports and the VTIMEZONE block is not mistaken for one`() {
|
||||||
|
assertThat(result.events.map { it.summary })
|
||||||
|
.containsExactly("Team standup", "Day off", "Christmas break")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `X-WR-CALNAME names the calendar for all of them`() {
|
||||||
|
assertThat(result.events.map { it.calendarName }.distinct())
|
||||||
|
.containsExactly("jane@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a TZID-qualified start resolves against the device tz database`() {
|
||||||
|
val standup = event("Team standup")
|
||||||
|
assertThat(standup.isAllDay).isFalse()
|
||||||
|
assertThat(standup.zoneId).isEqualTo("Europe/Berlin")
|
||||||
|
// 10:00 CEST is 08:00 UTC.
|
||||||
|
assertThat(standup.start.toString()).isEqualTo("2026-09-15T08:00:00Z")
|
||||||
|
assertThat((standup.end - standup.start).inWholeMinutes).isEqualTo(90)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the recurrence rule and its TZID-qualified EXDATE survive`() {
|
||||||
|
val standup = event("Team standup")
|
||||||
|
assertThat(standup.recurrenceRule).isEqualTo("FREQ=WEEKLY;BYDAY=TU")
|
||||||
|
assertThat(standup.exDates).containsExactly("20260929T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the moved occurrence is skipped rather than imported as a duplicate`() {
|
||||||
|
// Google writes a RECURRENCE-ID override as its own VEVENT carrying the
|
||||||
|
// master's UID. Importing it would put a second "standup" in the
|
||||||
|
// calendar; Calendula models no overrides, so it is reported instead.
|
||||||
|
assertThat(result.events.map { it.summary }).doesNotContain("Team standup (moved)")
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a folded DESCRIPTION is unfolded and unescaped`() {
|
||||||
|
assertThat(event("Team standup").description).isEqualTo(
|
||||||
|
"Weekly sync with the team.\nAgenda lives in the shared doc, see the link below.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an escaped comma in LOCATION comes back as a comma`() {
|
||||||
|
assertThat(event("Team standup").location).isEqualTo("Meeting room 2, 3rd floor")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `attendees are reported rather than silently dropped`() {
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed VALARM becomes its lead time in minutes`() {
|
||||||
|
assertThat(event("Team standup").semanticReminderMinutes()).containsExactly(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event keeps the single day the file gives it`() {
|
||||||
|
val dayOff = event("Day off")
|
||||||
|
assertThat(dayOff.isAllDay).isTrue()
|
||||||
|
assertThat((dayOff.end - dayOff.start).inWholeDays).isEqualTo(1)
|
||||||
|
assertThat(dayOff.availability).isEqualTo(Availability.Free)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day alarm comes back as whole days before`() {
|
||||||
|
// Google writes an all-day reminder as a whole-day offset from the
|
||||||
|
// event's UTC midnight; a day has to survive as a day.
|
||||||
|
assertThat(event("Day off").semanticReminderMinutes()).containsExactly(1440)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day all-day event keeps its exclusive DTEND span`() {
|
||||||
|
val christmas = event("Christmas break")
|
||||||
|
assertThat(christmas.isAllDay).isTrue()
|
||||||
|
assertThat((christmas.end - christmas.start).inWholeDays).isEqualTo(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no recurrence rule is repaired - Google writes them well-formed`() {
|
||||||
|
assertThat(result.warnings).doesNotContain(IcsParseWarning.RecurrenceRuleRepaired)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** What the Backup & restore screen offers for a given calendar list (#304). */
|
||||||
|
class BackupUiStateTest {
|
||||||
|
|
||||||
|
private fun cal(
|
||||||
|
id: Long,
|
||||||
|
local: Boolean = false,
|
||||||
|
writable: Boolean = true,
|
||||||
|
visible: Boolean = true,
|
||||||
|
managed: Boolean = false,
|
||||||
|
syncs: Boolean = true,
|
||||||
|
) = CalendarSource(
|
||||||
|
id = id,
|
||||||
|
displayName = "Calendar $id",
|
||||||
|
accountName = "acc@example.com",
|
||||||
|
accountType = if (local) "LOCAL" else "com.google",
|
||||||
|
color = 0,
|
||||||
|
isVisibleInSystem = visible,
|
||||||
|
canModifyContents = writable,
|
||||||
|
isLocal = local,
|
||||||
|
isManaged = managed,
|
||||||
|
syncsEvents = syncs,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun failure(state: BackupUiState) = (state as BackupUiState.Failure).reason
|
||||||
|
|
||||||
|
private fun ready(state: BackupUiState) = state as BackupUiState.Ready
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no calendars at all reports the empty device`() {
|
||||||
|
assertThat(failure(backupUiState(emptyList())))
|
||||||
|
.isEqualTo(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a synced writable calendar can receive a restore`() {
|
||||||
|
val state = ready(backupUiState(listOf(cal(1L))))
|
||||||
|
assertThat(state.canImport).isTrue()
|
||||||
|
assertThat(state.exportable).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the reported case - only a calendar that cannot take events`() {
|
||||||
|
// #304: a Google account with calendar sync switched off leaves nothing
|
||||||
|
// exportable and nothing importable, which used to render a blank screen.
|
||||||
|
assertThat(failure(backupUiState(listOf(cal(1L, syncs = false)))))
|
||||||
|
.isEqualTo(FailureReason.NoImportTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a read-only subscription is no import target either`() {
|
||||||
|
assertThat(failure(backupUiState(listOf(cal(1L, writable = false)))))
|
||||||
|
.isEqualTo(FailureReason.NoImportTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `everything switched off is reported as hidden, not as a missing target`() {
|
||||||
|
// The remedy differs: a visibility switch fixes this one.
|
||||||
|
assertThat(failure(backupUiState(listOf(cal(1L, visible = false)))))
|
||||||
|
.isEqualTo(FailureReason.AllCalendarsHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a hidden local calendar still exports`() {
|
||||||
|
// Export reads the provider directly, so system visibility is irrelevant
|
||||||
|
// to it — calling this a failure would hide a working action.
|
||||||
|
val state = ready(backupUiState(listOf(cal(1L, local = true, visible = false))))
|
||||||
|
assertThat(state.exportable).hasSize(1)
|
||||||
|
assertThat(state.canImport).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a managed special-dates mirror is neither exportable nor a target`() {
|
||||||
|
assertThat(failure(backupUiState(listOf(cal(1L, local = true, managed = true)))))
|
||||||
|
.isEqualTo(FailureReason.NoImportTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a local calendar alongside a synced one offers both halves`() {
|
||||||
|
val state = ready(backupUiState(listOf(cal(1L, local = true), cal(2L))))
|
||||||
|
assertThat(state.exportable.map { it.id }).containsExactly(1L)
|
||||||
|
assertThat(state.canImport).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Google Inc//Google Calendar 70.9054//EN
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
METHOD:PUBLISH
|
||||||
|
X-WR-CALNAME:jane@example.com
|
||||||
|
X-WR-TIMEZONE:Europe/Berlin
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Berlin
|
||||||
|
X-LIC-LOCATION:Europe/Berlin
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
TZNAME:CEST
|
||||||
|
DTSTART:19700329T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
TZNAME:CET
|
||||||
|
DTSTART:19701025T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=Europe/Berlin:20260915T100000
|
||||||
|
DTEND;TZID=Europe/Berlin:20260915T113000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=TU
|
||||||
|
EXDATE;TZID=Europe/Berlin:20260929T100000
|
||||||
|
DTSTAMP:20260912T183000Z
|
||||||
|
UID:0abcdef1234567890abcdef12345@google.com
|
||||||
|
CREATED:20260901T120000Z
|
||||||
|
DESCRIPTION:Weekly sync with the team.\nAgenda lives in the shared doc\, see
|
||||||
|
the link below.
|
||||||
|
LAST-MODIFIED:20260902T081500Z
|
||||||
|
LOCATION:Meeting room 2\, 3rd floor
|
||||||
|
SEQUENCE:0
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SUMMARY:Team standup
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
ORGANIZER;CN=Jane Doe:mailto:jane@example.com
|
||||||
|
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=John:m
|
||||||
|
ailto:john@example.com
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:This is an event reminder
|
||||||
|
TRIGGER:-PT30M
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=Europe/Berlin:20260929T140000
|
||||||
|
DTEND;TZID=Europe/Berlin:20260929T153000
|
||||||
|
DTSTAMP:20260912T183000Z
|
||||||
|
UID:0abcdef1234567890abcdef12345@google.com
|
||||||
|
RECURRENCE-ID;TZID=Europe/Berlin:20260929T100000
|
||||||
|
CREATED:20260901T120000Z
|
||||||
|
LAST-MODIFIED:20260910T091500Z
|
||||||
|
SEQUENCE:1
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SUMMARY:Team standup (moved)
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;VALUE=DATE:20261024
|
||||||
|
DTEND;VALUE=DATE:20261025
|
||||||
|
DTSTAMP:20260912T183000Z
|
||||||
|
UID:1bcdef01234567890abcdef23456@google.com
|
||||||
|
CREATED:20260820T101500Z
|
||||||
|
LAST-MODIFIED:20260820T101500Z
|
||||||
|
SEQUENCE:0
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SUMMARY:Day off
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:This is an event reminder
|
||||||
|
TRIGGER:-P1D
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;VALUE=DATE:20261224
|
||||||
|
DTEND;VALUE=DATE:20261227
|
||||||
|
DTSTAMP:20260912T183000Z
|
||||||
|
UID:2cdef012345678901abcdef34567@google.com
|
||||||
|
SEQUENCE:0
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SUMMARY:Christmas break
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
Reference in New Issue
Block a user