Render the app privacy policies from the app repos #9
@@ -5,3 +5,7 @@ dist
|
||||
.env
|
||||
.env.production
|
||||
*.log
|
||||
|
||||
# Fetched during the build by scripts/sync-external.mjs; never copied in — a
|
||||
# local checkout may be a symlink to a working copy on the developer's machine.
|
||||
external
|
||||
|
||||
@@ -12,3 +12,6 @@ node_modules/
|
||||
*.log
|
||||
# local screenshots
|
||||
.shots/
|
||||
|
||||
# App repositories fetched at build time (see scripts/sync-external.mjs).
|
||||
external/
|
||||
|
||||
@@ -7,6 +7,14 @@ WORKDIR /app
|
||||
# Use npm install (not npm ci): Astro 7's wasm32 optional deps (@emnapi/*)
|
||||
# make npm ci's strict lock-sync check fail across npm/node versions. install
|
||||
# reconciles the lockfile and builds reliably.
|
||||
# git: the build fetches the app repositories whose docs/PRIVACY.md this site
|
||||
# renders (scripts/sync-external.mjs, run from `prebuild`). node:slim ships
|
||||
# without it; the script would fall back to fetching the raw files over HTTPS,
|
||||
# but a shallow clone is the intended path and keeps the failure modes obvious.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
|
||||
@@ -15,6 +15,49 @@ npm run build # output -> dist/
|
||||
npm run preview # serve the built site locally
|
||||
```
|
||||
|
||||
## App privacy policies (rendered from the app repos)
|
||||
|
||||
`/calendula/privacy` and `/agendula/privacy` hold **no copy** of their prose.
|
||||
Each policy lives in its own app repository, as `docs/PRIVACY.md` with
|
||||
`title` / `description` / `updated` frontmatter, and is the single copy of that
|
||||
policy anywhere:
|
||||
|
||||
| Page | Source |
|
||||
|------|--------|
|
||||
| `/calendula/privacy` | [`jlmakiola/calendula`](https://codeberg.org/jlmakiola/calendula) → `docs/PRIVACY.md` |
|
||||
| `/agendula/privacy` | [`jlmakiola/agendula`](https://codeberg.org/jlmakiola/agendula) → `docs/PRIVACY.md` |
|
||||
|
||||
`scripts/sync-external.mjs` clones them (shallow, single-branch, no
|
||||
credentials) into `external/` before every build and dev server — it is wired
|
||||
to `prebuild` and `predev`, **not** to CI, because Coolify builds the site from
|
||||
the repo and a checkout that only ran in a Gitea job would never reach the
|
||||
deployed page. `src/content.config.ts` exposes each file as a content
|
||||
collection; the pages render it and supply the `<h1>` from `title`.
|
||||
|
||||
**To change a policy, edit it in the app repo, in a PR.** It reaches the live
|
||||
page on the site's next build.
|
||||
|
||||
```sh
|
||||
npm run sync:external # refresh the checkouts by hand
|
||||
CALENDULA_REF=some/branch npm run build # build against a branch
|
||||
AGENDULA_LOCAL=../agendula npm run dev # render a working copy, no network
|
||||
```
|
||||
|
||||
The build **fails** if either policy is missing, empty, or malformed. That is
|
||||
deliberate: a privacy page silently rendering nothing is the one failure this
|
||||
arrangement exists to prevent, and it is worth a red deploy.
|
||||
|
||||
### Getting a policy edit onto the site
|
||||
|
||||
A build is what publishes it, so:
|
||||
|
||||
- **Floor:** the daily `scheduled-deploy` cron rebuilds every morning, so any
|
||||
edit is live within a day without anyone doing anything.
|
||||
- **Immediate:** add a webhook in the app repo (Codeberg → Settings → Webhooks)
|
||||
pointing at the same Coolify deploy URL the cron uses, so a merge to the app's
|
||||
`main` triggers a site rebuild at once. One-time setup per app repo; the
|
||||
secret lives in Coolify, not here.
|
||||
|
||||
## Writing a post
|
||||
|
||||
Create a Markdown file in `src/content/blog/`, e.g. `my-post.md`:
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync:external": "node scripts/sync-external.mjs",
|
||||
"predev": "npm run sync:external",
|
||||
"prebuild": "npm run sync:external",
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Fetch the app repositories whose Markdown this site renders.
|
||||
//
|
||||
// Each app's docs/PRIVACY.md is the single copy of that policy: the app repo
|
||||
// owns it, and this site renders it through a content collection (see
|
||||
// src/content.config.ts). Nothing here is authored in this repository, and
|
||||
// nothing is written back — this only makes the sources present at build time.
|
||||
//
|
||||
// It runs from `prebuild` and `predev`, NOT from CI: Coolify builds the site
|
||||
// straight from the repo, so a checkout that only happened in a Gitea job
|
||||
// would never reach the deployed page.
|
||||
//
|
||||
// Refs default to `main`. Point a source at a branch with e.g.
|
||||
// AGENDULA_REF=feat/caldav-sync, or at a working copy on this machine with
|
||||
// AGENDULA_LOCAL=/path/to/agendula (which skips the network entirely).
|
||||
//
|
||||
// The guardrail is the point of the script: if a policy source is missing,
|
||||
// empty, or malformed, the build FAILS. A privacy page that silently renders
|
||||
// nothing is the one outcome this design exists to prevent.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const EXTERNAL = join(ROOT, 'external');
|
||||
|
||||
const SOURCES = [
|
||||
{
|
||||
name: 'calendula',
|
||||
repo: 'https://codeberg.org/jlmakiola/calendula.git',
|
||||
raw: (ref) => `https://codeberg.org/jlmakiola/calendula/raw/branch/${ref}/docs/PRIVACY.md`,
|
||||
file: 'docs/PRIVACY.md',
|
||||
},
|
||||
{
|
||||
name: 'agendula',
|
||||
repo: 'https://codeberg.org/jlmakiola/agendula.git',
|
||||
raw: (ref) => `https://codeberg.org/jlmakiola/agendula/raw/branch/${ref}/docs/PRIVACY.md`,
|
||||
file: 'docs/PRIVACY.md',
|
||||
},
|
||||
];
|
||||
|
||||
const REQUIRED_FRONTMATTER = ['title', 'description', 'updated'];
|
||||
const MIN_BODY_CHARS = 400;
|
||||
|
||||
const env = (name, suffix) => process.env[`${name.toUpperCase()}_${suffix}`]?.trim() || '';
|
||||
const log = (msg) => console.log(`[external] ${msg}`);
|
||||
|
||||
const git = (args, cwd) =>
|
||||
execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
|
||||
|
||||
/** Shallow single-branch clone, or a shallow fetch if it is already there. */
|
||||
function syncRepo(source, ref, dir) {
|
||||
if (existsSync(join(dir, '.git'))) {
|
||||
git(['fetch', '--depth', '1', 'origin', ref], dir);
|
||||
git(['checkout', '--detach', 'FETCH_HEAD'], dir);
|
||||
return `fetched ${ref}`;
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
git(['clone', '--depth', '1', '--single-branch', '--branch', ref, source.repo, dir]);
|
||||
return `cloned ${ref}`;
|
||||
}
|
||||
|
||||
/** Last resort when git is unavailable or the clone fails: the raw file. */
|
||||
async function fetchRaw(source, ref, dir) {
|
||||
const res = await fetch(source.raw(ref), { headers: { Accept: 'text/plain' } });
|
||||
if (!res.ok) throw new Error(`raw fetch returned HTTP ${res.status}`);
|
||||
const body = await res.text();
|
||||
const target = join(dir, source.file);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, body);
|
||||
return `fetched ${source.file} at ${ref} over HTTPS`;
|
||||
}
|
||||
|
||||
/** Parse just enough frontmatter to know the entry will satisfy the schema. */
|
||||
function validate(source, dir) {
|
||||
const path = join(dir, source.file);
|
||||
if (!existsSync(path)) return `${source.file} is missing`;
|
||||
const text = readFileSync(path, 'utf8');
|
||||
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) return `${source.file} has no frontmatter block`;
|
||||
const [, frontmatter, body] = match;
|
||||
const missing = REQUIRED_FRONTMATTER.filter(
|
||||
(key) => !new RegExp(`^${key}:\\s*\\S`, 'm').test(frontmatter)
|
||||
);
|
||||
if (missing.length) return `${source.file} frontmatter is missing: ${missing.join(', ')}`;
|
||||
// Strip HTML comments — the maintainer note must not count as content.
|
||||
const prose = body.replace(/<!--[\s\S]*?-->/g, '').trim();
|
||||
if (prose.length < MIN_BODY_CHARS) return `${source.file} body is empty or too short`;
|
||||
if (!/^##\s/m.test(prose)) return `${source.file} body has no sections`;
|
||||
return null;
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
mkdirSync(EXTERNAL, { recursive: true });
|
||||
|
||||
for (const source of SOURCES) {
|
||||
const dir = join(EXTERNAL, source.name);
|
||||
const local = env(source.name, 'LOCAL');
|
||||
const ref = env(source.name, 'REF') || 'main';
|
||||
|
||||
if (local) {
|
||||
// Development escape hatch: render a working copy on this machine, for a
|
||||
// policy edit that is not pushed yet. Never set in CI or on the server.
|
||||
const target = isAbsolute(local) ? local : resolve(ROOT, local);
|
||||
if (!existsSync(target)) {
|
||||
console.error(`[external] ${source.name}: ${source.name.toUpperCase()}_LOCAL points at ${target}, which does not exist`);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
if (existsSync(dir) || lstatSync(dir, { throwIfNoEntry: false })) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
symlinkSync(target, dir, 'dir');
|
||||
log(`${source.name}: local override -> ${target}`);
|
||||
} else {
|
||||
try {
|
||||
log(`${source.name}: ${syncRepo(source, ref, dir)}`);
|
||||
} catch (error) {
|
||||
const reason = (error.stderr?.toString() || error.message).split('\n')[0];
|
||||
log(`${source.name}: git failed (${reason}); falling back to the raw file`);
|
||||
try {
|
||||
log(`${source.name}: ${await fetchRaw(source, ref, dir)}`);
|
||||
} catch (fallbackError) {
|
||||
log(`${source.name}: fallback failed (${fallbackError.message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const problem = validate(source, dir);
|
||||
if (problem) {
|
||||
console.error(`[external] ${source.name}: ${problem}`);
|
||||
failed = true;
|
||||
} else {
|
||||
log(`${source.name}: ${source.file} ok`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(
|
||||
'\n[external] A privacy policy source is unusable, so the build stops here.\n' +
|
||||
' These pages must never render empty — fix the source, or set\n' +
|
||||
' <APP>_REF / <APP>_LOCAL if you are working against a branch.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
+22
-1
@@ -48,4 +48,25 @@ const projects = defineCollection({
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog, projects };
|
||||
// App privacy policies. The Markdown lives in each app's own repository and is
|
||||
// the only copy of that policy; scripts/sync-external.mjs puts the repo under
|
||||
// external/ before a build or a dev server, and the pages under
|
||||
// src/pages/<app>/privacy.astro render it. Nothing here is authored in this
|
||||
// repo — edit the policy in the app repo, in a PR.
|
||||
const policySchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
updated: z.coerce.date(),
|
||||
});
|
||||
|
||||
const calendulaPolicy = defineCollection({
|
||||
loader: glob({ pattern: 'PRIVACY.md', base: './external/calendula/docs' }),
|
||||
schema: policySchema,
|
||||
});
|
||||
|
||||
const agendulaPolicy = defineCollection({
|
||||
loader: glob({ pattern: 'PRIVACY.md', base: './external/agendula/docs' }),
|
||||
schema: policySchema,
|
||||
});
|
||||
|
||||
export const collections = { blog, projects, calendulaPolicy, agendulaPolicy };
|
||||
|
||||
@@ -1,328 +1,31 @@
|
||||
---
|
||||
import { render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { LEGAL } from '../../consts';
|
||||
import { getPolicy } from '../../utils/policy';
|
||||
|
||||
// App privacy policy for Agendula — required as a public URL by the Google Play
|
||||
// Console (mandatory for every app) and linked from the app's About card.
|
||||
// Deliberately separate from /datenschutz, which covers this website only.
|
||||
// The policy itself lives in the Agendula repository, at docs/PRIVACY.md, and is
|
||||
// rendered from there — this page holds no copy of the prose, so the published
|
||||
// page and the app's own documentation cannot drift apart. Edit the policy in
|
||||
// https://codeberg.org/jlmakiola/agendula, in a PR.
|
||||
//
|
||||
// Kept in English because SITE.lang is 'en' and Play's default store listing is
|
||||
// en-US; /datenschutz and /impressum stay German for legal reasons.
|
||||
// The URL is load-bearing: /agendula/privacy is what the app's Settings row
|
||||
// opens and what the Play Console field holds. Do not move or rename it.
|
||||
//
|
||||
// Source of truth for the facts below: the Agendula repository (docs/PRIVACY.md,
|
||||
// docs/SYNC.md, AndroidManifest.xml, backup_rules.xml). Unlike Calendula,
|
||||
// Agendula *does* hold INTERNET — it has its own CalDAV sync — so the wording
|
||||
// here turns on "only to the server you entered", not on the absence of the
|
||||
// permission. If sync, permissions or data paths change, update this page and
|
||||
// the "Last updated" date.
|
||||
const lastUpdated = '9 September 2026';
|
||||
// Title and description come from the file's frontmatter; the body carries its
|
||||
// own "last updated" line and deliberately starts below the h1 this page
|
||||
// supplies.
|
||||
const entry = await getPolicy('agendulaPolicy');
|
||||
const { Content } = await render(entry);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Agendula — Privacy Policy"
|
||||
description="Privacy policy for the Agendula Android task app. No accounts, no analytics, no tracking — data leaves your device only for the CalDAV server you choose."
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
type="article"
|
||||
width="narrow"
|
||||
>
|
||||
<article class="prose">
|
||||
<h1 class="page-title">Privacy Policy — Agendula</h1>
|
||||
|
||||
<p>
|
||||
<strong>Last updated:</strong> {lastUpdated}<br />
|
||||
Applies to the Android app <strong>Agendula</strong>{' '}
|
||||
(package <code>de.jeanlucmakiola.agendula</code>), all versions and all
|
||||
distribution channels.
|
||||
</p>
|
||||
|
||||
<h2>In short</h2>
|
||||
<p>
|
||||
Agendula has no servers, no user accounts and no analytics. Your tasks live
|
||||
on your device. They leave it in exactly one case: if you set up a CalDAV
|
||||
account yourself, they are synchronised with <strong>the server you
|
||||
entered</strong> — and with nothing and no one else. Nothing is ever sent
|
||||
to the developer.
|
||||
</p>
|
||||
|
||||
<h2>1. Controller</h2>
|
||||
<p>
|
||||
{LEGAL.business}<br />
|
||||
{LEGAL.street}<br />
|
||||
{LEGAL.city}<br />
|
||||
Email: <a href={`mailto:${LEGAL.email}`}>{LEGAL.email}</a>
|
||||
</p>
|
||||
|
||||
<h2>2. No data collection by the developer</h2>
|
||||
<p>
|
||||
Agendula contains <strong>no analytics, no tracking, no advertising, no
|
||||
crash-reporting SDK and no third-party service that reports anything
|
||||
anywhere</strong>. No user profile is created, no advertising or device
|
||||
identifier is generated, and no data is shared with or sold to anyone.
|
||||
There is no Agendula account, and the developer operates no server that the
|
||||
app talks to.
|
||||
</p>
|
||||
<p>
|
||||
All of this is verifiable in the{' '}
|
||||
<a href="https://codeberg.org/jlmakiola/agendula" rel="noopener">source code</a>,
|
||||
which is public.
|
||||
</p>
|
||||
|
||||
<h2>3. Where your tasks live — your choice</h2>
|
||||
<p>
|
||||
Agendula offers two storage modes, and you pick one:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>On your device (the default)</strong> — your task lists, tasks and
|
||||
reminders are kept in Agendula's own database inside the app's private
|
||||
storage. Nothing is published to other apps, and uninstalling the app
|
||||
removes it.
|
||||
</li>
|
||||
<li>
|
||||
<strong>In a tasks provider you already use</strong> — OpenTasks or
|
||||
tasks.org. Agendula then reads and writes that app's task database
|
||||
through Android's provider mechanism, after you grant its read/write
|
||||
permission. Whatever already synchronises that provider (DAVx5,
|
||||
SmoothSync, DecSync CC, …) keeps doing so, unchanged; that synchronisation
|
||||
is performed by those apps, not by Agendula, and their privacy policies
|
||||
apply to it.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. CalDAV sync — the only case where your tasks leave the device</h2>
|
||||
<p>
|
||||
Sync is optional and off until you add an account. If you add one,
|
||||
everything below happens between your device and <strong>the server you
|
||||
nominated</strong>, and nowhere else.
|
||||
</p>
|
||||
|
||||
<h3>What is stored on your device</h3>
|
||||
<p>
|
||||
The server address, your username, and your password or app password. The
|
||||
password is encrypted with a key held in the Android Keystore, which cannot
|
||||
be exported from the device.
|
||||
</p>
|
||||
|
||||
<h3>What is transmitted, and to whom</h3>
|
||||
<ul>
|
||||
<li>
|
||||
The tasks in the synchronised lists, as standard iCalendar
|
||||
(<code>VTODO</code>) data, and the credentials needed to authenticate.
|
||||
</li>
|
||||
<li>
|
||||
Requests carry the user agent <code>Agendula (Android)</code> — a fixed
|
||||
string, so that you can recognise and revoke the session on your server.
|
||||
No device identifier is sent.
|
||||
</li>
|
||||
<li>
|
||||
Connections are HTTPS. Cleartext HTTP is refused, so credentials are
|
||||
never sent over an unencrypted connection.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Under Google Play's Data Safety definitions this counts as{' '}
|
||||
<strong>collected</strong> — Play defines collection as transmitting data
|
||||
off the device, regardless of who receives it — and <strong>not
|
||||
shared</strong>, because the only recipient is the server you chose. Data is
|
||||
encrypted in transit.
|
||||
</p>
|
||||
|
||||
<h3>Finding your server</h3>
|
||||
<p>
|
||||
When you type a server address or an email domain, Agendula follows the
|
||||
standard discovery procedure (RFC 6764): a DNS lookup for the{' '}
|
||||
<code>_caldavs._tcp</code> service record of that domain, then{' '}
|
||||
<code>/.well-known/caldav</code> on the host. The DNS query goes to whichever
|
||||
resolver your device or network uses, and the requests go to the domain you
|
||||
typed — no directory of servers is consulted and no lookup is sent to the
|
||||
developer.
|
||||
</p>
|
||||
|
||||
<h3>Signing in to a Nextcloud</h3>
|
||||
<p>
|
||||
If the server is a Nextcloud, Agendula uses Nextcloud's Login Flow v2: your
|
||||
browser opens <em>your own server's</em> login page, you authorise there,
|
||||
and the server hands the app a dedicated app password. Agendula never sees
|
||||
your actual account password. The app password appears in your server's
|
||||
“Devices & sessions” list as <code>Agendula (Android)</code>, and you can
|
||||
revoke it there at any time. Removing the account in Agendula revokes it too,
|
||||
where the server supports that.
|
||||
</p>
|
||||
|
||||
<h3>Your server's own policy</h3>
|
||||
<p>
|
||||
Your CalDAV provider has its own privacy policy, and your data on their
|
||||
server is governed by it. Agendula has no relationship with them.
|
||||
</p>
|
||||
<p>
|
||||
A note on certificates: Agendula trusts private certificate authorities that
|
||||
you have installed in your device's user store, because self-hosted servers
|
||||
routinely use them. That is a deliberate trade-off in favour of
|
||||
self-hosters — any CA installed on your device (for example by an employer's
|
||||
management profile) can, in principle, intercept traffic from the app, as it
|
||||
can from other apps that make the same choice.
|
||||
</p>
|
||||
|
||||
<h2>5. Other data Agendula handles on your device</h2>
|
||||
|
||||
<h3>Reminders and notifications</h3>
|
||||
<p>
|
||||
Due-date reminders are scheduled by the app itself and displayed as local
|
||||
notifications. Nothing is sent to a push service — there is no push service.
|
||||
</p>
|
||||
|
||||
<h3>Export files</h3>
|
||||
<p>
|
||||
You can export your tasks as standard iCalendar <code>.ics</code> files.
|
||||
Agendula writes exactly the file you select through Android's system file
|
||||
picker, and has no access to other files.
|
||||
</p>
|
||||
|
||||
<h3>App settings</h3>
|
||||
<p>
|
||||
Your preferences (theme, language, list and reminder defaults and similar)
|
||||
are stored locally on your device and are removed when you uninstall the
|
||||
app.
|
||||
</p>
|
||||
|
||||
<h2>6. Backups</h2>
|
||||
<p>
|
||||
If Android Auto Backup is enabled on your device, your tasks and settings
|
||||
may be backed up to your own Google account, under Google's terms — the
|
||||
developer has no access to it. Two things are deliberately excluded from
|
||||
that backup: your stored CalDAV password, and Agendula's per-device sync
|
||||
bookkeeping. After restoring onto a new device you therefore sign in to your
|
||||
server again.
|
||||
</p>
|
||||
|
||||
<h2>7. Crash reports</h2>
|
||||
<p>
|
||||
If Agendula crashes, it offers to report the problem. Nothing is sent
|
||||
automatically, even though the app has network access. The report is copied
|
||||
to your clipboard and your browser is opened with the project's issue
|
||||
tracker, the text pre-filled. <strong>You see the full content, you decide
|
||||
whether to submit it, and you can edit or discard it.</strong>
|
||||
</p>
|
||||
<p>Such a report contains:</p>
|
||||
<ul>
|
||||
<li>app version,</li>
|
||||
<li>Android version,</li>
|
||||
<li>device manufacturer and model,</li>
|
||||
<li>your device language,</li>
|
||||
<li>the timestamp,</li>
|
||||
<li>and the technical stack trace.</li>
|
||||
</ul>
|
||||
<p>
|
||||
It is built from that fixed list and nothing else: <strong>no</strong> task
|
||||
data, <strong>no</strong> server address or credentials, <strong>no</strong>{' '}
|
||||
account names, <strong>no</strong> log files and <strong>no</strong> personal
|
||||
identifiers.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>8. External links</h2>
|
||||
<p>
|
||||
The app links to the source code, the licence, the issue tracker, the
|
||||
translation platform (Weblate) 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. Agendula transmits no
|
||||
data of yours in the process — it only opens the address.
|
||||
</p>
|
||||
|
||||
<h2>9. Permissions and why they exist</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<code>INTERNET</code>, <code>ACCESS_NETWORK_STATE</code> — CalDAV sync with
|
||||
the server you configure, and checking whether a connection exists before
|
||||
trying. Without a CalDAV account, no connection is made.
|
||||
</li>
|
||||
<li>
|
||||
<code>READ_SYNC_SETTINGS</code>, <code>WRITE_SYNC_SETTINGS</code> — register
|
||||
the sync account with Android's sync framework so it can be scheduled.
|
||||
</li>
|
||||
<li><code>POST_NOTIFICATIONS</code> — show reminders.</li>
|
||||
<li>
|
||||
<code>USE_EXACT_ALARM</code>, <code>SCHEDULE_EXACT_ALARM</code> — deliver
|
||||
reminders at the exact due time.
|
||||
</li>
|
||||
<li>
|
||||
<code>RECEIVE_BOOT_COMPLETED</code> — re-register pending reminders after a
|
||||
restart.
|
||||
</li>
|
||||
<li>
|
||||
<code>org.dmfs.permission.READ_TASKS</code> /{' '}
|
||||
<code>WRITE_TASKS</code> and <code>org.tasks.permission.READ_TASKS</code> /{' '}
|
||||
<code>WRITE_TASKS</code> — optional, requested only if you choose the
|
||||
external-provider storage mode, and only for the provider you selected
|
||||
(OpenTasks or tasks.org).
|
||||
</li>
|
||||
<li>
|
||||
<code>WAKE_LOCK</code>, <code>FOREGROUND_SERVICE</code> — required by the
|
||||
Android system component used for scheduled background work
|
||||
(WorkManager); on older Android versions it needs them to run an
|
||||
expedited sync.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Agendula publishes no content provider of its own and declares no
|
||||
permissions that other apps could request.
|
||||
</p>
|
||||
|
||||
<h2>10. Distribution channels</h2>
|
||||
<p>
|
||||
Agendula 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.
|
||||
</p>
|
||||
|
||||
<h2>11. Children</h2>
|
||||
<p>
|
||||
Agendula is not directed at children and collects nothing about anyone.
|
||||
</p>
|
||||
|
||||
<h2>12. Deleting your data</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Remove a CalDAV account</strong> (Settings → Accounts) deletes the
|
||||
stored credential and, where the server supports it, revokes the app
|
||||
password. The task lists become device-only lists rather than being
|
||||
destroyed.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Remove an account and delete its local data</strong> removes those
|
||||
lists and their tasks as well.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Uninstalling the app</strong> removes everything Agendula stored on
|
||||
the device.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Data on your CalDAV server is deleted on that server; data in an external
|
||||
tasks provider is deleted in that app.
|
||||
</p>
|
||||
|
||||
<h2>13. Your rights</h2>
|
||||
<p>
|
||||
The developer stores no personal data of yours — the only data transfer the
|
||||
app performs is between your device and a server you operate or chose. There
|
||||
is therefore no data held by the developer to which rights of access,
|
||||
rectification, erasure, restriction, data portability or objection
|
||||
(Art. 15–21 GDPR) could apply. Your tasks are exportable as standard{' '}
|
||||
<code>.ics</code> files from within the app at any time. You may contact the
|
||||
address above with any question, and you have the right to lodge a complaint
|
||||
with a supervisory authority.
|
||||
</p>
|
||||
|
||||
<h2>14. Changes to this policy</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<h1 class="page-title">{entry.data.title}</h1>
|
||||
<Content />
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,221 +1,31 @@
|
||||
---
|
||||
import { render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { LEGAL } from '../../consts';
|
||||
import { getPolicy } from '../../utils/policy';
|
||||
|
||||
// App privacy policy for Calendula — required as a public URL by the Google
|
||||
// Play Console (mandatory for every app) and linked from the app's About card.
|
||||
// Deliberately separate from /datenschutz, which covers this website only.
|
||||
// The policy itself lives in the Calendula repository, at docs/PRIVACY.md, and is
|
||||
// rendered from there — this page holds no copy of the prose, so the published
|
||||
// page and the app's own documentation cannot drift apart. Edit the policy in
|
||||
// https://codeberg.org/jlmakiola/calendula, in a PR.
|
||||
//
|
||||
// Kept in English because SITE.lang is 'en' and Play's default store listing is
|
||||
// en-US; /datenschutz and /impressum stay German for legal reasons.
|
||||
// The URL is load-bearing: /calendula/privacy is what the app's Settings row
|
||||
// opens and what the Play Console field holds. Do not move or rename it.
|
||||
//
|
||||
// Source of truth for the facts below: the Calendula repository. If the app's
|
||||
// permissions or data paths change, update this page and the "Last updated"
|
||||
// date. The claim "no INTERNET permission" is verifiable via
|
||||
// `aapt2 dump permissions` on any released APK.
|
||||
const lastUpdated = '28 July 2026';
|
||||
// Title and description come from the file's frontmatter; the body carries its
|
||||
// own "last updated" line and deliberately starts below the h1 this page
|
||||
// supplies.
|
||||
const entry = await getPolicy('calendulaPolicy');
|
||||
const { Content } = await render(entry);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Calendula — Privacy Policy"
|
||||
description="Privacy policy for the Calendula Android calendar app. No data collection, no tracking, no internet permission."
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
type="article"
|
||||
width="narrow"
|
||||
>
|
||||
<article class="prose">
|
||||
<h1 class="page-title">Privacy Policy — Calendula</h1>
|
||||
|
||||
<p>
|
||||
<strong>Last updated:</strong> {lastUpdated}<br />
|
||||
Applies to the Android app <strong>Calendula</strong>{' '}
|
||||
(package <code>de.jeanlucmakiola.calendula</code>), all versions and all
|
||||
distribution channels.
|
||||
</p>
|
||||
|
||||
<h2>In short</h2>
|
||||
<p>
|
||||
Calendula collects nothing, sends nothing, and has no user accounts.
|
||||
It has <strong>no internet permission at all</strong> — 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.
|
||||
</p>
|
||||
|
||||
<h2>1. Controller</h2>
|
||||
<p>
|
||||
{LEGAL.business}<br />
|
||||
{LEGAL.street}<br />
|
||||
{LEGAL.city}<br />
|
||||
Email: <a href={`mailto:${LEGAL.email}`}>{LEGAL.email}</a>
|
||||
</p>
|
||||
|
||||
<h2>2. No data collection</h2>
|
||||
<p>
|
||||
Calendula contains <strong>no analytics, no tracking, no advertising, no
|
||||
crash-reporting SDK and no third-party services of any kind</strong>. No
|
||||
user profile is created, no identifier is generated, and no data is shared
|
||||
with or sold to anyone.
|
||||
</p>
|
||||
<p>
|
||||
The app does not request the <code>android.permission.INTERNET</code> 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 <a href="https://codeberg.org/jlmakiola/calendula" rel="noopener">source code</a>.
|
||||
</p>
|
||||
|
||||
<h2>3. Data Calendula accesses on your device</h2>
|
||||
<p>
|
||||
All of the following is processed <strong>locally on your device only</strong>.
|
||||
None of it is transmitted, and none of it is stored by the developer.
|
||||
</p>
|
||||
|
||||
<h3>Calendar data</h3>
|
||||
<p>
|
||||
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 <strong>no database of
|
||||
its own</strong> — your events live in the system calendar, exactly where
|
||||
they lived before you installed Calendula, and they remain there if you
|
||||
uninstall it.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Contacts (optional)</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Notifications</h3>
|
||||
<p>
|
||||
Reminders are displayed as local notifications on your device. Nothing is
|
||||
sent to a push service.
|
||||
</p>
|
||||
|
||||
<h3>Files</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>App settings</h3>
|
||||
<p>
|
||||
Your preferences (view options, theme, reminder defaults and similar) are
|
||||
stored locally on your device and are removed when you uninstall the app.
|
||||
</p>
|
||||
|
||||
<h2>4. Crash reports — the only case where data can leave your device</h2>
|
||||
<p>
|
||||
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. <strong>You see the full content, you decide whether to submit
|
||||
it, and you can edit or discard it.</strong>
|
||||
</p>
|
||||
<p>Such a report contains:</p>
|
||||
<ul>
|
||||
<li>app version,</li>
|
||||
<li>Android version,</li>
|
||||
<li>device manufacturer and model,</li>
|
||||
<li>your device language,</li>
|
||||
<li>the timestamp,</li>
|
||||
<li>and the technical stack trace.</li>
|
||||
</ul>
|
||||
<p>
|
||||
It contains <strong>no</strong> event data, <strong>no</strong> contacts
|
||||
and <strong>no</strong> personal identifiers.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>5. External links</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>6. Permissions and why they exist</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<code>READ_CALENDAR</code>, <code>WRITE_CALENDAR</code> — display and
|
||||
edit your events; the core function.
|
||||
</li>
|
||||
<li><code>POST_NOTIFICATIONS</code> — show reminders.</li>
|
||||
<li>
|
||||
<code>READ_CONTACTS</code> — optional, only for the “Contact special
|
||||
dates” feature.
|
||||
</li>
|
||||
<li>
|
||||
<code>USE_EXACT_ALARM</code>, <code>SCHEDULE_EXACT_ALARM</code> — deliver
|
||||
reminders at the exact time, including after snoozing.
|
||||
</li>
|
||||
<li>
|
||||
<code>RECEIVE_BOOT_COMPLETED</code> — re-register pending reminders after
|
||||
a restart.
|
||||
</li>
|
||||
<li>
|
||||
<code>REQUEST_IGNORE_BATTERY_OPTIMIZATIONS</code> — only to open the
|
||||
system dialog for the “Reliable delivery” setting.
|
||||
</li>
|
||||
<li>
|
||||
<code>WAKE_LOCK</code>, <code>FOREGROUND_SERVICE</code>, <code>ACCESS_NETWORK_STATE</code>{' '}
|
||||
— required by the Android system component used for scheduled background
|
||||
work (WorkManager).
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<code>ACCESS_NETWORK_STATE</code> allows reading <em>whether</em> a network
|
||||
connection exists — it does <strong>not</strong> permit using one.
|
||||
Without <code>INTERNET</code>, no connection is possible.
|
||||
</p>
|
||||
|
||||
<h2>7. Distribution channels</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>8. Children</h2>
|
||||
<p>
|
||||
Calendula is suitable for all ages. Since it collects no data at all, no
|
||||
data of children is processed either.
|
||||
</p>
|
||||
|
||||
<h2>9. Your rights</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>10. Changes to this policy</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<h1 class="page-title">{entry.data.title}</h1>
|
||||
<Content />
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
/**
|
||||
* The single entry of an app's privacy-policy collection.
|
||||
*
|
||||
* The Markdown lives in the app's own repository (see scripts/sync-external.mjs);
|
||||
* this site holds no copy. If the checkout is missing or empty the collection
|
||||
* resolves nothing, and a privacy page that renders nothing is worse than a
|
||||
* failed build — so this throws rather than returning undefined.
|
||||
*/
|
||||
export async function getPolicy(collection: 'calendulaPolicy' | 'agendulaPolicy') {
|
||||
const entries = await getCollection(collection);
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(
|
||||
`[policy] ${collection} resolved ${entries.length} entries, expected exactly 1. ` +
|
||||
'The app repository under external/ is missing, empty, or holds more than ' +
|
||||
'one PRIVACY.md. Run `npm run sync:external` and read its output.'
|
||||
);
|
||||
}
|
||||
return entries[0];
|
||||
}
|
||||
Reference in New Issue
Block a user