Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c0b875f0 | ||
|
|
512bf8c746 | ||
|
|
04257e8a66 | ||
|
|
c04b2ecee2 |
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.20.1] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
- **An empty calendar now explains itself.** "No calendars configured" offered
|
||||
an "Open system calendar settings" button that opened nothing — every screen
|
||||
wired it to the same reload that had just produced the empty state. It now
|
||||
opens the system's account chooser, filtered to the providers that sync
|
||||
calendars ([#239]).
|
||||
- **Calendars that are all switched off no longer look like an empty month.**
|
||||
When every calendar on the device is hidden at system level there is nothing
|
||||
to draw, but the app said nothing at all: it only ever checked whether the
|
||||
device had *no* calendars, so a device that had them and hid them fell
|
||||
through to a blank grid with no hint. That case now says so, and points at
|
||||
Settings → Calendars — the one screen that can switch a calendar back on. The
|
||||
navigation drawer's calendar list drew the same conflation and is fixed with
|
||||
it ([#239]).
|
||||
|
||||
## [2.20.0] — 2026-09-07
|
||||
|
||||
### Added
|
||||
@@ -1654,3 +1671,4 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
|
||||
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
||||
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
||||
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||
|
||||
@@ -157,6 +157,8 @@ reinstalling. Google Play is the exception — see above.</sub>
|
||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
||||
- **[Privacy policy](docs/PRIVACY.md)** — the source of the page published at
|
||||
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -187,6 +189,16 @@ No coding needed — register on the Weblate server, pick (or request) a languag
|
||||
and translate the strings in your browser. You can also reach this link in the
|
||||
app from the top of **Settings → App language**.
|
||||
|
||||
## 🔒 Privacy
|
||||
|
||||
No analytics, no tracking, no advertising, no crash-reporting SDK, no third-party
|
||||
services — and **no internet permission at all**, so the app is technically
|
||||
incapable of transmitting anything anywhere. Your events stay in Android's own
|
||||
calendar store, where they already were.
|
||||
|
||||
**→ [Privacy policy](https://jeanlucmakiola.de/calendula/privacy)** — written in
|
||||
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
|
||||
|
||||
## 📜 License
|
||||
|
||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 22000
|
||||
versionName = "2.20.0"
|
||||
versionCode = 22001
|
||||
versionName = "2.20.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -46,3 +46,19 @@ fun hasSystemHiddenCalendars(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||
|
||||
/**
|
||||
* The failure a calendar list implies, or null when there is something to show.
|
||||
* Distinguishes "the device has no calendars" from "every one of them is
|
||||
* switched off" (#239) — both leave the grid empty, but only the second is
|
||||
* fixed from Settings → Calendars.
|
||||
*
|
||||
* The filter sheet's own hidden set is deliberately not folded in: emptying it
|
||||
* is a deliberate act taken moments earlier, in a sheet that is still one tap
|
||||
* away.
|
||||
*/
|
||||
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
|
||||
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
|
||||
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -252,6 +252,7 @@ enum class RecurringWriteScope {
|
||||
enum class FailureReason {
|
||||
PermissionRevoked,
|
||||
NoCalendarsConfigured,
|
||||
AllCalendarsHidden,
|
||||
ProviderUnavailable,
|
||||
EventNotFound,
|
||||
Unknown,
|
||||
|
||||
@@ -31,6 +31,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
@@ -344,7 +345,12 @@ fun CalendarHost(
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
val viewSwitch = fadeThrough()
|
||||
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
||||
CompositionLocalProvider(
|
||||
LocalEventMove provides moveScope,
|
||||
// The failure state's way out when every calendar is switched off
|
||||
// (#239); nothing else in the calendar surfaces navigates here.
|
||||
LocalManageCalendars provides { showCalendars = true },
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -156,9 +157,7 @@ class AgendaViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
||||
val anchor = params.anchor
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -10,8 +14,10 @@ import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -24,18 +30,29 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
val titleRes = when (reason) {
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
||||
FailureReason.Unknown,
|
||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
||||
}
|
||||
val actionRes = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden_action
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||
else -> R.string.state_retry
|
||||
}
|
||||
val onAction: () -> Unit = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> {
|
||||
{ context.startCalendarSetup() }
|
||||
}
|
||||
FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry
|
||||
else -> onRetry
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -49,8 +66,30 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
FilledTonalButton(onClick = onRetry) {
|
||||
FilledTonalButton(onClick = onAction) {
|
||||
Text(stringResource(actionRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens Settings → Calendars, the only screen that can switch a calendar back
|
||||
* on. Null outside the calendar host — screens without it never raise the
|
||||
* reason that needs it, and fall back to their own retry.
|
||||
*/
|
||||
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
/**
|
||||
* Open the system screen for putting a calendar on the device: the account
|
||||
* chooser filtered to calendar providers, falling back to account settings
|
||||
* where no chooser resolves. Both are system components, so nothing here needs
|
||||
* a `<queries>` entry.
|
||||
*/
|
||||
private fun Context.startCalendarSetup() {
|
||||
val intents = listOf(
|
||||
Intent(Settings.ACTION_ADD_ACCOUNT)
|
||||
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
|
||||
Intent(Settings.ACTION_SYNC_SETTINGS),
|
||||
)
|
||||
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -88,9 +89,7 @@ class DayViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): DayUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return DayUiState.Failure(it) }
|
||||
val days = listOf(day)
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
|
||||
@@ -125,6 +125,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
||||
val msg = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
else -> R.string.state_failure_provider
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -35,8 +36,9 @@ class FilterViewModel @Inject constructor(
|
||||
// Calendars switched off device-wide don't belong in the drawer's
|
||||
// hide/show list; they live only in Settings → Calendars.
|
||||
val enabled = calendars.filter { it.isVisibleInSystem }
|
||||
if (enabled.isEmpty()) {
|
||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
val failure = calendarListFailure(calendars)
|
||||
if (failure != null) {
|
||||
FilterUiState.Failure(failure)
|
||||
} else {
|
||||
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
@@ -208,9 +209,7 @@ class MonthViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): ContinuousMonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
|
||||
// Bucketed once for the whole window: a row then takes the handful of
|
||||
// events on its seven days instead of re-scanning a year of them.
|
||||
val byDay = DayIndex(instances, zone)
|
||||
@@ -314,9 +313,7 @@ class MonthViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): MonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||
return MonthUiState.Success(
|
||||
month = ym,
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -126,9 +127,7 @@ class WeekViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): WeekUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="state_failure_permission_action">Grant access</string>
|
||||
<string name="state_failure_no_calendars">No calendars configured.</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_action">Manage calendars</string>
|
||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||
|
||||
<!-- Long-press a field to copy it (#195) -->
|
||||
|
||||
@@ -121,4 +121,24 @@ class CalendarVisibilityPlanTest {
|
||||
),
|
||||
).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no calendars on the device is its own failure`() {
|
||||
assertThat(calendarListFailure(emptyList()))
|
||||
.isEqualTo(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendars that are all switched off read as hidden, not missing`() {
|
||||
// The remedy differs: one needs a calendar made, the other needs one
|
||||
// switched back on (#239).
|
||||
assertThat(calendarListFailure(listOf(cal(1L, visible = false), cal(2L, visible = false))))
|
||||
.isEqualTo(FailureReason.AllCalendarsHidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one visible calendar is enough to show something`() {
|
||||
assertThat(calendarListFailure(listOf(cal(1L, visible = false), cal(2L, visible = true))))
|
||||
.isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,12 @@ class ModelsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FailureReason enum has all five variants`() {
|
||||
fun `FailureReason enum has all six variants`() {
|
||||
assertThat(FailureReason.values().toSet()).isEqualTo(
|
||||
setOf(
|
||||
FailureReason.PermissionRevoked,
|
||||
FailureReason.NoCalendarsConfigured,
|
||||
FailureReason.AllCalendarsHidden,
|
||||
FailureReason.ProviderUnavailable,
|
||||
FailureReason.EventNotFound,
|
||||
FailureReason.Unknown,
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
---
|
||||
title: Privacy Policy — Calendula
|
||||
description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
|
||||
updated: 2026-07-28
|
||||
---
|
||||
|
||||
<!--
|
||||
THIS FILE IS THE POLICY. Edit it here, in a PR, reviewed like any other
|
||||
change — it is the source of truth.
|
||||
|
||||
It is published at https://jeanlucmakiola.de/calendula/privacy, which is what
|
||||
the app's Settings → About → Privacy policy row opens and what the Play
|
||||
Console field must hold. That page holds no copy of the prose: the website
|
||||
build checks this repository out beside itself and renders this file through
|
||||
an Astro content collection, so there is exactly one copy of the policy
|
||||
anywhere and the two cannot drift. An edit here reaches the live page on the
|
||||
site's next build.
|
||||
|
||||
The page supplies its own <h1> from the `title` above, so this body starts at
|
||||
the first section — do not add one. This comment is HTML so that it cannot
|
||||
render on the published page.
|
||||
-->
|
||||
|
||||
**Last updated:** 28 July 2026
|
||||
Applies to the Android app **Calendula** (package `de.jeanlucmakiola.calendula`), all versions and all distribution channels.
|
||||
|
||||
## In short
|
||||
|
||||
Calendula collects nothing, sends nothing, and has no user accounts. It has **no internet permission at all** — the app is technically incapable of transmitting your data anywhere. Everything it shows you is read from the calendars that already exist on your device.
|
||||
|
||||
## 1. Controller
|
||||
|
||||
IT-Dienstleister | Jean-Luc Makiola
|
||||
Mahlerstraße 10
|
||||
14772 Brandenburg an der Havel
|
||||
Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)
|
||||
|
||||
## 2. No data collection
|
||||
|
||||
Calendula contains **no analytics, no tracking, no advertising, no crash-reporting SDK and no third-party services of any kind**. No user profile is created, no identifier is generated, and no data is shared with or sold to anyone.
|
||||
|
||||
The app does not request the `android.permission.INTERNET` permission. Without it, Android prevents the app from opening any network connection. This is verifiable: inspect the permission list of any released APK, or read the [source code](https://codeberg.org/jlmakiola/calendula).
|
||||
|
||||
## 3. Data Calendula accesses on your device
|
||||
|
||||
All of the following is processed **locally on your device only**. None of it is transmitted, and none of it is stored by the developer.
|
||||
|
||||
### Calendar data
|
||||
|
||||
Calendula is a viewer and editor for the calendars Android already manages. It reads and writes events, reminders and calendar settings through Android's system calendar provider. The app keeps **no database of its own** — your events live in the system calendar, exactly where they lived before you installed Calendula, and they remain there if you uninstall it.
|
||||
|
||||
Note: if one of those system calendars is itself synchronised with an online account (for example a Google account, or a CalDAV server via DAVx5), that synchronisation is performed by Android and that other app — not by Calendula. The privacy policy of the respective provider applies to it.
|
||||
|
||||
### Contacts (optional)
|
||||
|
||||
The “Contact special dates” feature reads birthdays and anniversaries from your contacts and mirrors them one-way into a local calendar, so they appear alongside your other events. This feature is switched off by default, the contacts permission is requested only when you enable it, and it is never requested at startup. Contacts are only ever read, never modified, and the data does not leave your device.
|
||||
|
||||
### Notifications
|
||||
|
||||
Reminders are displayed as local notifications on your device. Nothing is sent to a push service.
|
||||
|
||||
### Files
|
||||
|
||||
When you import or export an ICS file, Calendula reads or writes exactly the file you select through Android's system file picker. The optional automatic backup writes an ICS export to the folder you choose. The app has no access to other files.
|
||||
|
||||
### App settings
|
||||
|
||||
Your preferences (view options, theme, reminder defaults and similar) are stored locally on your device and are removed when you uninstall the app.
|
||||
|
||||
## 4. Crash reports — the only case where data can leave your device
|
||||
|
||||
If Calendula crashes, it offers to report the problem. Nothing is sent automatically. The report is copied to your clipboard and your browser is opened with the project's issue tracker, the text pre-filled. **You see the full content, you decide whether to submit it, and you can edit or discard it.**
|
||||
|
||||
Such a report contains:
|
||||
|
||||
- app version,
|
||||
- Android version,
|
||||
- device manufacturer and model,
|
||||
- your device language,
|
||||
- the timestamp,
|
||||
- and the technical stack trace.
|
||||
|
||||
It contains **no** event data, **no** contacts and **no** personal identifiers.
|
||||
|
||||
If you choose to submit it, the report becomes a public issue on the project's issue tracker at Codeberg, operated by Codeberg e. V. Their privacy policy then applies to that submission.
|
||||
|
||||
## 5. External links
|
||||
|
||||
The settings screen contains links to the source code, the licence, the issue tracker and a voluntary donation page (Ko-fi). Following one of these links opens your browser and leaves the app; the privacy policy of the respective website then applies. Calendula transmits no data of yours in the process — it only opens the address.
|
||||
|
||||
## 6. Permissions and why they exist
|
||||
|
||||
- `READ_CALENDAR`, `WRITE_CALENDAR` — display and edit your events; the core function.
|
||||
- `POST_NOTIFICATIONS` — show reminders.
|
||||
- `READ_CONTACTS` — optional, only for the “Contact special dates” feature.
|
||||
- `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM` — deliver reminders at the exact time, including after snoozing.
|
||||
- `RECEIVE_BOOT_COMPLETED` — re-register pending reminders after a restart.
|
||||
- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — only to open the system dialog for the “Reliable delivery” setting.
|
||||
- `WAKE_LOCK`, `FOREGROUND_SERVICE`, `ACCESS_NETWORK_STATE` — required by the Android system component used for scheduled background work (WorkManager).
|
||||
|
||||
`ACCESS_NETWORK_STATE` allows reading *whether* a network connection exists — it does **not** permit using one. Without `INTERNET`, no connection is possible.
|
||||
|
||||
## 7. Distribution channels
|
||||
|
||||
Calendula is distributed via F-Droid, Obtainium, a self-hosted repository and, where applicable, the Google Play Store. When you download or update the app, the operator of that channel processes data (such as your IP address) under their own privacy policy. This is outside the developer's control and unrelated to the app's own behaviour.
|
||||
|
||||
## 8. Children
|
||||
|
||||
Calendula is suitable for all ages. Since it collects no data at all, no data of children is processed either.
|
||||
|
||||
## 9. Your rights
|
||||
|
||||
Because the developer processes no personal data of yours, there is no stored data to which rights of access, rectification, erasure, restriction, data portability or objection (Art. 15–21 GDPR) could apply. You may nevertheless contact the address above at any time with any question. You also have the right to lodge a complaint with a supervisory authority.
|
||||
|
||||
## 10. Changes to this policy
|
||||
|
||||
Should the app's functionality change in a way that affects data processing, this policy will be updated and the date at the top adjusted. The version history is publicly traceable in the project's source repository.
|
||||
@@ -0,0 +1,11 @@
|
||||
New
|
||||
• Long-press an event's title, location or description to copy.
|
||||
• Settings → About lists the open-source projects we ship.
|
||||
|
||||
Changed
|
||||
• Week & day: each hour sits in its own cell, not a line.
|
||||
• Unanswered invitations are drawn as outlines.
|
||||
|
||||
Fixed
|
||||
• "No calendars configured" now really opens the system calendar setup.
|
||||
• When every calendar is switched off, the app says so instead of a blank month.
|
||||
Reference in New Issue
Block a user