10 KiB
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/; 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, orbiweekly(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-backedMasterKey). 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 tohttps://. - 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-TOproperty, 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. - 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
AlarmManagerexact 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
PRIORITY1-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/YEARLYwithINTERVAL/COUNT/UNTIL) is evaluated client-side to re-date each item to its next upcoming occurrence. See "Known limitations" 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 — no manual refresh needed. Note this means a widget's content is only as fresh as the last time the app itself synced (on open, on manual pull-to-refresh, or after an edit) — the widgets read from the local cache on their own ~30-minute OS redraw timer, they don't independently hit the network.
Architecture
- Kotlin + Jetpack Compose (Material 3), MVVM, single-activity, no DI framework
(
ui/ViewModelFactory.ktis a small hand-rolled factory instead). CalDavClient(data/network/) — hand-rolled CalDAV client over OkHttp. SpeaksPROPFIND(discover calendars/task-lists),REPORT(calendar-queryto fetchVEVENT/VTODO), andPUT/DELETEwithIf-Match/If-None-Matchfor safe, conflict-aware writes. Every successfulPUTcaptures the server's returnedETagso the next edit doesn't race against a stale local copy.biweekly— parses/generates the actual iCalendar (RFC 5545) payloads, includingVALARM(reminders) andRELATED-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 RoomFlows; every write goes throughsave()/delete(), which do the network call first, then update the cache, reminders, and widgets.ReminderScheduler(notifications/) — schedules one exactAlarmManageralarm 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
- Create a Nextcloud app password — not your real login password. Settings → Security → Devices & sessions → Create new app password.
- Open
ncal/in Android Studio (Koala/2024.1 or newer). Let it sync Gradle. - Run on a device or emulator (min SDK 26 / Android 8+).
- On first launch, enter your server URL, username, and the app password.
- Go to the Calendars screen (gear icon) and confirm which calendars/task-lists are enabled.
- Optional: long-press your home screen → Widgets → NCal, then add "Calendar Month" and/or "Tasks".
Known limitations
- Editing an existing recurring item drops its
RRULE. Saving rebuilds the.icsfrom the edit form's fields, so an edited recurring item becomes a one-off. Viewing/completing/deleting a recurring item is safe; editing its fields is not, if you want the series to survive the save.CalendarItem.rawIcsretains the original document for exactly this reason — wiringtoIcs()to mutate the parsed original in place is the natural fix if this turns out to matter in practice. - No recurrence-editing UI — no picker to set a recurrence rule on a new item.
- Subtask tree is app-side only. The Month view and both widgets show tasks flat/chronological, not nested — only the in-app Tasks screen renders the parent/ child tree.
- No background sync while the app is closed. Sync happens on app open, manual
pull-to-refresh, or after an edit — there's no periodic
WorkManagerjob callingrepository.fullSync()yet, so a widget can go stale if you never open the app. - Last-write-wins conflict handling. A genuinely stale write fails loudly (a
CalDavExceptionwith the HTTP code) rather than silently clobbering a change made elsewhere — but there's no merge UI, just an error surfaced to the edit screen.
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.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.