186 lines
11 KiB
Markdown
186 lines
11 KiB
Markdown
# NCal
|
||
|
||
A native Android app that talks **directly to Nextcloud's CalDAV endpoint** — no
|
||
DAVx⁵, no separate tasks app, no Android Calendar/Tasks Provider in the middle. One
|
||
app, full CRUD, for both events and tasks, plus subtasks, reminders, and home screen
|
||
widgets — built to run on phones with **no Google Play Services at all**.
|
||
|
||
The Android project lives in [`ncal/`](ncal/); open that folder in Android Studio.
|
||
|
||
## Why this exists
|
||
|
||
No mainstream Android app natively renders both `VEVENT` (events) and `VTODO` (tasks)
|
||
from CalDAV in one UI. DAVx⁵ + Etar + Tasks.org gets you three apps for one job. This
|
||
is the fourth option: one app, one account, one sync engine.
|
||
|
||
## Privacy & security
|
||
|
||
- **No Google Play Services, anywhere.** Sync, reminders, and the home screen widgets
|
||
all use plain AOSP/AndroidX APIs (`AlarmManager`, `NotificationManager`,
|
||
`androidx.glance`, `androidx.work`) — every one of these is a core platform
|
||
capability, not a Google service, so the app runs unmodified on GrapheneOS,
|
||
LineageOS, or any other de-googled ROM.
|
||
- **No analytics, crash reporting, or ad SDKs.** Check `ncal/app/build.gradle.kts` —
|
||
every dependency is either AndroidX/Jetpack, OkHttp, or `biweekly` (iCalendar
|
||
parsing). Nothing phones home to a vendor.
|
||
- **No third-party server.** The only network calls in the app go to whatever
|
||
Nextcloud server URL *you* enter at login (`data/network/CalDavClient.kt`). There is
|
||
no hardcoded third-party endpoint anywhere in the source.
|
||
- **Everything is encrypted at rest.** Server URL, username, and app password live in
|
||
`EncryptedSharedPreferences` (`data/prefs/SecurePrefs.kt`, AES-256-GCM/SIV via an
|
||
Android Keystore-backed `MasterKey`). The local Room cache itself is an
|
||
SQLCipher-encrypted database (`ncal_encrypted.db`), not plain SQLite — nothing the
|
||
app writes to disk, including the widgets' own tiny bit of navigation state, is ever
|
||
unencrypted. `android:allowBackup="false"` also keeps Android's cloud backup from
|
||
ever exporting any of it off-device.
|
||
- **HTTPS only.** `usesCleartextTraffic="false"` blocks plain HTTP at the OS network
|
||
layer, and the login screen always normalizes whatever you type to `https://`.
|
||
- **Local-first.** The calendar/task cache is a local, encrypted database. It exists
|
||
purely so the UI renders instantly and works offline; the server is always the
|
||
source of truth on next sync.
|
||
|
||
## Features
|
||
|
||
- **Agenda** — unified, day-grouped list of events *and* tasks. Syncs automatically
|
||
every time the app is opened, plus pull to refresh any time.
|
||
- **Month view** — a full calendar grid with events rendered as spanning colored
|
||
pills and tasks as dot + text, exactly mirroring what's on the home screen widget
|
||
(they share the same layout algorithm). Tap a day to see its agenda below the grid.
|
||
- **Tasks** — dedicated checklist view. Tasks render as an indented tree under their
|
||
parent, not a flat list.
|
||
- **Subtasks** — assign any task a parent task (stored as the standard iCalendar
|
||
`RELATED-TO` property, so it round-trips with Nextcloud's own Tasks app and other
|
||
CalDAV clients). The parent picker excludes the task itself and its own descendants
|
||
to prevent cycles. The Tasks screen *and* the Tasks widget both render the real
|
||
parent/child tree (`util/TaskTree.kt` is shared by both).
|
||
- **Recurrence** — a Repeats picker (Daily/Weekly/Monthly/Yearly, interval, and an end
|
||
condition of never/after N times/on a date) for both events and tasks, backed by a
|
||
real iCal `RRULE`.
|
||
- **Conflict handling** — a stale save (someone else changed or deleted the item since
|
||
you opened it) surfaces every field that actually differs and lets you pick, per
|
||
field, whether your edit or the server's current value wins ("All mine"/"All
|
||
theirs" shortcuts too), instead of just failing with an error.
|
||
- **Reminders** — add one or more reminders to any event or task (at the scheduled
|
||
time, or a preset offset before it: 5 min up to 1 week). Reminders fire as real
|
||
system notifications via `AlarmManager` exact alarms — not a best-effort background
|
||
job — and survive reboots and app updates. Tapping a reminder notification opens
|
||
the app directly to that item.
|
||
- **Task status, priority, % complete, location, and URL** — full VTODO fields, not
|
||
just a completed checkbox: a proper status (Not started / In progress / Completed /
|
||
Cancelled), an iCal `PRIORITY` 1-9 picker color-coded 1-4 red/5 yellow/6-9 blue
|
||
(surfaced as the task's leading dot everywhere it's shown — Tasks list, Month view,
|
||
both widgets), a 0-100% completion slider, and location/URL fields shared with
|
||
events.
|
||
- **Home screen widgets** (see below).
|
||
- **Calendars** — pick which calendars/task-lists sync, mirroring what you'd see in
|
||
Nextcloud's own sidebar.
|
||
- **Recurring events and tasks** — `RRULE` (`FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with
|
||
`INTERVAL`/`COUNT`/`UNTIL`) is evaluated client-side to re-date each item to its
|
||
next upcoming occurrence. See "Editing recurring items" below for what this doesn't cover.
|
||
|
||
## Home screen widgets
|
||
|
||
Two widgets, built with Jetpack Glance (`androidx.glance:glance-appwidget`), which
|
||
compiles down to plain `RemoteViews`/`AppWidgetProvider` — no Google dependency:
|
||
|
||
- **Month grid widget** ("Calendar Month" in the widget picker) — the exact same
|
||
month view as the in-app Month screen (shared layout code in
|
||
`util/MonthGridLayout.kt`), with ‹ › month navigation and tap-to-open on any event
|
||
or task. Resizable, and fonts/day-circle size/event-lane count all scale up when
|
||
you make the widget bigger instead of leaving blank space.
|
||
- **Next Tasks widget** ("Tasks" in the widget picker) — a scrollable list of
|
||
incomplete tasks grouped and ordered by due date, with a tap-to-complete circle on
|
||
each row (color-coded by priority) and tap-to-open on the row itself.
|
||
|
||
Both widgets refresh automatically whenever you sync, save, or delete something in
|
||
the app, and independently of the app being open at all: a `WorkManager` periodic job
|
||
(`sync/SyncScheduler.kt`, `sync/SyncWorker.kt`) calls `repository.fullSync()` roughly
|
||
every 30 minutes whenever the device has network - matching the widgets' own OS-level
|
||
redraw floor, since syncing more often than a widget can even show an update buys
|
||
nothing.
|
||
|
||
## Architecture
|
||
|
||
- **Kotlin + Jetpack Compose (Material 3)**, MVVM, single-activity, no DI framework
|
||
(`ui/ViewModelFactory.kt` is a small hand-rolled factory instead).
|
||
- **`CalDavClient`** (`data/network/`) — hand-rolled CalDAV client over OkHttp. Speaks
|
||
`PROPFIND` (discover calendars/task-lists), `REPORT` (`calendar-query` to fetch
|
||
`VEVENT`/`VTODO`), and `PUT`/`DELETE` with `If-Match`/`If-None-Match` for safe,
|
||
conflict-aware writes. Every successful `PUT` captures the server's returned `ETag`
|
||
so the next edit doesn't race against a stale local copy.
|
||
- **`biweekly`** — parses/generates the actual iCalendar (RFC 5545) payloads,
|
||
including `VALARM` (reminders) and `RELATED-TO` (subtasks).
|
||
- **Room, on an SQLCipher-encrypted database** (`data/db/`) — local cache so every
|
||
screen (and both widgets) render instantly and survive being offline, without ever
|
||
touching disk unencrypted.
|
||
- **`NextcloudRepository`** — single source of truth gluing the network client, the
|
||
DB, reminder scheduling, and widget refresh together. Every screen reads from Room
|
||
`Flow`s; every write goes through `save()`/`delete()`, which do the network call
|
||
first, then update the cache, reminders, and widgets.
|
||
- **`ReminderScheduler`** (`notifications/`) — schedules one exact `AlarmManager`
|
||
alarm per item, always for its *next* upcoming reminder offset; the receiver
|
||
re-arms the following offset each time it fires, so there's never more than one
|
||
pending alarm per item to track.
|
||
- **`MonthGridWidget` / `NextTasksWidget`** (`widget/`) — Glance widgets that read
|
||
straight from the same repository the app uses.
|
||
|
||
## Setup
|
||
|
||
1. **Create a Nextcloud app password** — not your real login password.
|
||
Settings → Security → Devices & sessions → Create new app password.
|
||
2. **Open `ncal/` in Android Studio** (Koala/2024.1 or newer). Let it sync Gradle.
|
||
3. Run on a device or emulator (min SDK 26 / Android 8+).
|
||
4. On first launch, enter your server URL, username, and the app password.
|
||
5. Go to the **Calendars** screen (gear icon) and confirm which calendars/task-lists
|
||
are enabled.
|
||
6. Optional: long-press your home screen → Widgets → NCal, then add "Calendar Month"
|
||
and/or "Tasks".
|
||
|
||
Run the (small, growing) JVM unit test suite with `./gradlew test` — no emulator
|
||
needed.
|
||
|
||
## Editing recurring items
|
||
|
||
Recurring items get two guarantees, both regression-tested in `IcsMapperTest`:
|
||
|
||
- **Editing the series' actual start/end/due time is safe.** [CalendarItem.start]/`end`/
|
||
`due` hold the *resolved next occurrence* for a recurring item (what every list/grid
|
||
displays), not the master date - so the edit screen shows/edits a separate
|
||
`recurrenceMasterStart`/`End`/`Due` for a recurring item instead, with a note that
|
||
the change applies to the whole series. Editing them writes the true master
|
||
`DTSTART`/`DTEND`/`DUE`; leaving them untouched round-trips the original value
|
||
exactly, so an unrelated edit (e.g. just the title) can never silently shift a
|
||
series forward the way it used to.
|
||
- **A rule the Repeats picker can't represent is left completely alone.** `BYDAY`/
|
||
`BYMONTH`/etc-based rules (or anything outside Daily/Weekly/Monthly/Yearly) show as
|
||
"Does not repeat" in the picker but are never flattened or overwritten by it.
|
||
|
||
## Where to look first if you want to extend it
|
||
|
||
- `data/network/CalDavClient.kt` — all server communication.
|
||
- `util/IcsMapper.kt` — the iCalendar ⇄ app-model translation layer (events, tasks,
|
||
subtasks, reminders).
|
||
- `util/MonthGridLayout.kt` — the month-grid layout algorithm shared by the in-app
|
||
Month screen and the month widget.
|
||
- `data/repository/NextcloudRepository.kt` — the sync/cache/reminder/widget
|
||
orchestration.
|
||
- `notifications/ReminderScheduler.kt` — reminder alarm scheduling.
|
||
- `sync/SyncWorker.kt` / `sync/SyncScheduler.kt` — the periodic background sync job.
|
||
- `util/TaskTree.kt` — the parent/child ordering shared by the Tasks screen and widget.
|
||
- `util/RecurrenceUtils.kt` — re-dates a recurring item to its next occurrence for display.
|
||
- `data/repository/SaveConflictException.kt` — the 412-conflict data carried to the edit screen's dialog.
|
||
- `util/ConflictDiff.kt` — computes which fields actually differ for the merge dialog.
|
||
- `widget/` — the two Glance home screen widgets.
|
||
|
||
## Building a signed release
|
||
|
||
`ncal/app/build.gradle.kts` reads signing credentials from a git-ignored
|
||
`ncal/keystore.properties` (`storePassword`, `keyPassword`, `keyAlias`, `storeFile`) —
|
||
generate your own keystore and fill that file in; nothing under `ncal/keystore/` or
|
||
`ncal/keystore.properties` is tracked by git, and no signing key ships in this repo.
|
||
Without that file present, `:app:assembleRelease` still produces an **unsigned** APK.
|
||
|
||
## License
|
||
|
||
GPL-3.0 — see [`LICENSE`](LICENSE).
|