Compare commits

...
4 Commits
Author SHA1 Message Date
Jean-Luc Makiola a0c0b875f0 Move the privacy policy into the repo (#293)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/293
2026-09-09 16:23:47 +02:00
makiolaj 512bf8c746 docs: shape the policy as the content entry the site renders
Frontmatter carries the title, description and date, the body starts at
the first section rather than repeating the title as an h1, and the
maintainer note is an HTML comment — a blockquote would have rendered
"edit this in a PR" onto the published privacy page.
2026-09-09 16:14:42 +02:00
makiolaj 04257e8a66 docs: the privacy policy moves into the repo
The policy lived only as prose inside the website's Astro page, so it was
the one user-facing document with no review, no history here, and no way
to change it alongside the code that makes it true.

docs/PRIVACY.md is now that text, transcribed from the published page so
the wording is unchanged, and it becomes the source: the site's build
checks this repository out beside itself and renders this file through an
Astro content collection rather than keeping its own copy. One copy of the
policy exists anywhere, so the two cannot drift.

The README links it from Documentation and gains a Privacy section beside
License. Agendula is being set up the same way.
2026-09-09 16:13:42 +02:00
Jean-Luc Makiolaandmakiolaj c04b2ecee2 Release v2.20.1 (#288)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 31m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m22s
Release 2.20.1.

**Fixed**

- "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]).
- A device whose calendars are all switched off no longer looks like an empty month. The app only ever checked whether the device had *no* calendars, so calendars hidden at system level fell through to a blank grid with no hint. That case now says so and points at Settings → Calendars. The navigation drawer's calendar list drew the same conflation and is fixed with it ([#239]).

**Beyond the issue**

Two things in here aren't part of #239:

- `fastlane/metadata/android/en-US/changelogs/22001.txt` carries the 2.20.0 notes as well as 2.20.1's. The 2.20.0 Play upload failed on a service-account permission (it lacked "Release to production"; 2.19.4 failed the same way), so Play is still on 2.19.3 and users jump straight to 2.20.1. Left alone they would have seen only the 2.20.1 bugfixes and never heard about the hour grid, copyable event fields or invitation outlines. 402 characters, inside Play's 500 limit. The permission has since been granted, so this release should be the one that lands.
- A merge of `main` into the branch. `main` took 2.20.0 as a squash, so the two had no common ancestor past 2.19.4 and this PR would otherwise have replayed all 74 files of 2.20.0. Resolved to the branch throughout; the tree is byte-for-byte unchanged by it.

Closes #239.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/288
2026-09-07 22:29:03 +02:00
18 changed files with 262 additions and 22 deletions
+18
View File
@@ -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
+12
View File
@@ -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
+2 -2
View File
@@ -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 }
+2
View File
@@ -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
View File
@@ -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. 1521 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.