Ncal/README.md

186 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).