Ncal/README.md

10 KiB
Raw Blame History

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, 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.
  • 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 tasksRRULE (FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL/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.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 Flows; 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".

Known limitations

  • Editing an existing recurring item drops its RRULE. Saving rebuilds the .ics from 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.rawIcs retains the original document for exactly this reason — wiring toIcs() 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 WorkManager job calling repository.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 CalDavException with 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.