diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 7fba181..89f086a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -253,6 +253,13 @@ enum class FailureReason { PermissionRevoked, NoCalendarsConfigured, 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, EventNotFound, Unknown, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 0141d94..f34b83c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -550,18 +550,24 @@ fun CalendarHost( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), ) { - BackupScreen( - onBack = { showBackup = false }, - // Restore runs the normal .ics import, and both this screen and - // the manager that can have opened it are declared above the - // import overlays — so both have to step aside. - onImport = { - importUri = it - importForceMany = true - showBackup = false - showCalendars = false - }, - ) + // The manager sits directly under this screen, so its failure states' + // "Manage calendars" way out is simply popping back to it (#304). + CompositionLocalProvider( + LocalManageCalendars provides { showBackup = false }, + ) { + BackupScreen( + onBack = { showBackup = false }, + // Restore runs the normal .ics import, and both this screen + // and the manager that can have opened it are declared above + // the import overlays — so both have to step aside. + onImport = { + importUri = it + importForceMany = true + showBackup = false + showCalendars = false + }, + ) + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index f32663f..9a1fd48 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -4,6 +4,7 @@ import android.net.Uri import android.text.format.DateUtils import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.Button import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost 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.SettingsPrefs 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.CalendarFailure 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.DialogAmountField import de.jeanlucmakiola.floret.components.DialogUnitDropdown @@ -80,18 +83,13 @@ fun BackupScreen( onImport: (Uri) -> Unit, viewModel: CalendarsViewModel = hiltViewModel(), ) { - val calendars by viewModel.calendars.collectAsStateWithLifecycle() + val state by viewModel.backupState.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() val context = LocalContext.current 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 // own launcher. val createBackup = rememberLauncherForActivityResult( @@ -135,75 +133,35 @@ fun BackupScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, ) { - HintText(stringResource(R.string.calendars_backup_hint)) - - if (exportable.isNotEmpty()) { - GroupedRow( - title = stringResource(R.string.calendars_backup_action), - position = Position.Top, - leading = { LeadingAvatar(Icons.Default.FileDownload) }, - onClick = { - // A single exportable calendar skips the selector. - if (exportable.size == 1) { - runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } - } else { - showExportPicker = true - } + when (val s = state) { + BackupUiState.Loading -> BackupLoading() + is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack) + is BackupUiState.Ready -> BackupContent( + exportable = s.exportable, + canImport = s.canImport, + autoBackup = autoBackup, + viewModel = viewModel, + onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, + onPickFolder = { runCatching { pickFolder.launch(null) } }, + onExportAll = { + runCatching { createBackup.launch(defaultBackupName()) } }, - ) - GroupedRow( - 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) } }, + onExportPick = { showExportPicker = true }, + onEditInterval = { showInterval = true }, ) } } - if (showExportPicker) { - ExportCalendarPicker( - calendars = exportable, - onExport = viewModel::exportBackup, - onDismiss = { showExportPicker = false }, - ) + // Gated on Ready rather than resetting the flag when the state leaves it: + // flipping it here would be a write during composition. + (state as? BackupUiState.Ready)?.let { ready -> + if (showExportPicker) { + ExportCalendarPicker( + calendars = ready.exportable, + onExport = viewModel::exportBackup, + onDismiss = { showExportPicker = false }, + ) + } } if (showInterval) { 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, + 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 * to all selected; the Export action opens the SAF save dialog and hands back diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt new file mode 100644 index 0000000..3be7c6b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt @@ -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, + 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): 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) +} 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 49576df..38eadec 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 @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow @@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor( 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 = + 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. */ val autoBackup: StateFlow = combine( settingsPrefs.autoBackupEnabled, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt index 8eee97a..794e38f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt @@ -36,6 +36,7 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { FailureReason.PermissionRevoked -> R.string.state_failure_permission FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars 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.Unknown, FailureReason.EventNotFound -> R.string.state_failure_unknown @@ -43,6 +44,7 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { val actionRes = when (reason) { FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_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 else -> R.string.state_retry } @@ -50,7 +52,9 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { FailureReason.NoCalendarsConfigured -> { { context.startCalendarSetup() } } - FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry + FailureReason.AllCalendarsHidden, + FailureReason.NoImportTarget, + -> manageCalendars ?: onRetry else -> onRetry } Column( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bdc610f..81224c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Open system calendar settings All your calendars are switched off. Manage calendars + None of your calendars can take new events. They are read-only, managed by another app, or not synced to this device. Could not read the calendar. @@ -679,6 +680,7 @@ Restore Restore from .ics file Import events from a backup or another calendar app. + No calendar can take the imported events yet. Automatic backup Periodically export your local calendars to a folder as an .ics file. Backup folder diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt index 324acf6..25d4503 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt @@ -36,12 +36,13 @@ class ModelsTest { } @Test - fun `FailureReason enum has all six variants`() { + fun `FailureReason enum has all seven variants`() { assertThat(FailureReason.values().toSet()).isEqualTo( setOf( FailureReason.PermissionRevoked, FailureReason.NoCalendarsConfigured, FailureReason.AllCalendarsHidden, + FailureReason.NoImportTarget, FailureReason.ProviderUnavailable, FailureReason.EventNotFound, FailureReason.Unknown, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsGoogleImportTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsGoogleImportTest.kt new file mode 100644 index 0000000..df3dd5a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsGoogleImportTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt new file mode 100644 index 0000000..2d71c97 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt @@ -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() + } +} diff --git a/app/src/test/resources/ics/google-calendar-export.ics b/app/src/test/resources/ics/google-calendar-export.ics new file mode 100644 index 0000000..8c7dcd1 --- /dev/null +++ b/app/src/test/resources/ics/google-calendar-export.ics @@ -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