feat: import birthdays from Android Contacts

Mirror the iOS Contacts birthday importer: read the address book's birthday
events and reconcile them into the user's birthday calendar, scoped to this
device via external_uid 'contact:<deviceId>:<contactId>' (add/update/delete),
then report the device.

- READ_CONTACTS permission + ContactsReader (Event TYPE_BIRTHDAY, handles
  year/no-year date formats)
- API: GET /birthdays, POST /birthdays/sync-report; local-event create/update
  now carry external_uid (+ update carries rrule/birth_year)
- CalendarViewModel.syncContactBirthdays reconciles like iOS; device-local
  enable flag + stable device id in SettingsStore
- birthday dialog gains an 'Import from contacts' action (requests permission);
  auto-syncs on launch when enabled

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-07-15 18:48:24 +02:00
parent e042f92ec6
commit 3212fe2c6a
10 changed files with 205 additions and 4 deletions

View File

@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<application <application
android:name=".CalendarrApplication" android:name=".CalendarrApplication"

View File

@@ -355,9 +355,9 @@ class CalendarRepository @Inject constructor(
calendarId: Int, title: String, start: Instant, end: Instant, calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null, isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded { ) = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear)) api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess() .ensureSuccess()
} }
@@ -365,8 +365,9 @@ class CalendarRepository @Inject constructor(
uid: String, title: String, start: Instant, end: Instant, uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null, isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded { ) = guarded {
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders)) api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess() .ensureSuccess()
} }
@@ -618,7 +619,7 @@ class CalendarRepository @Inject constructor(
calendarId: Int?, title: String, start: Instant, end: Instant, calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null, isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = jsonBody( ) = jsonBody(
buildMap { buildMap {
calendarId?.let { put("calendar_id", it) } calendarId?.let { put("calendar_id", it) }
@@ -633,6 +634,17 @@ class CalendarRepository @Inject constructor(
if (reminders != null) put("reminders", org.json.JSONArray(reminders)) if (reminders != null) put("reminders", org.json.JSONArray(reminders))
if (!rrule.isNullOrBlank()) put("rrule", rrule) if (!rrule.isNullOrBlank()) put("rrule", rrule)
if (birthYear != null) put("birth_year", birthYear) if (birthYear != null) put("birth_year", birthYear)
if (!externalUid.isNullOrBlank()) put("external_uid", externalUid)
} }
) )
// ---- Birthdays (Contacts import) ----
suspend fun getBirthdayEntries(calendarId: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry> =
guarded { api.getBirthdays(calendarId) }
suspend fun reportBirthdaySync(deviceId: String, deviceName: String, count: Int) = guarded {
api.reportBirthdaySync(jsonBody("device_id" to deviceId, "device_name" to deviceName, "count" to count))
.ensureSuccess()
}
} }

View File

@@ -0,0 +1,66 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.provider.ContactsContract
/** One birthday read from the address book. */
data class ContactBirthday(
val contactId: String,
val name: String,
val month: Int,
val day: Int,
val year: Int?,
)
/** Reads birthdays from the system Contacts (requires READ_CONTACTS). */
object ContactsReader {
fun readBirthdays(context: Context): List<ContactBirthday> {
val out = mutableListOf<ContactBirthday>()
val projection = arrayOf(
ContactsContract.Data.CONTACT_ID,
ContactsContract.CommonDataKinds.Event.START_DATE,
ContactsContract.Data.DISPLAY_NAME,
)
val selection = "${ContactsContract.Data.MIMETYPE} = ? AND ${ContactsContract.CommonDataKinds.Event.TYPE} = ?"
val args = arrayOf(
ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY.toString(),
)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)
?.use { c ->
val idIdx = c.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
val dateIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Event.START_DATE)
val nameIdx = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME)
while (c.moveToNext()) {
val raw = c.getString(dateIdx) ?: continue
val name = c.getString(nameIdx)?.takeIf { it.isNotBlank() } ?: continue
val id = c.getString(idIdx) ?: continue
val ymd = parseDate(raw) ?: continue
out.add(ContactBirthday(id, name, ymd.second, ymd.third, ymd.first))
}
}
// A contact could carry more than one birthday row — keep the first.
return out.distinctBy { it.contactId }
}
/** Returns (year?, month, day). Handles "yyyy-MM-dd", "--MM-dd" (no year),
* and "yyyyMMdd". */
private fun parseDate(raw: String): Triple<Int?, Int, Int>? = try {
val s = raw.trim()
when {
s.startsWith("--") -> {
val p = s.removePrefix("--").split("-")
Triple(null, p[0].toInt(), p[1].toInt())
}
s.contains("-") -> {
val p = s.split("-")
if (p.size == 3) Triple(p[0].toIntOrNull()?.takeIf { it > 0 }, p[1].toInt(), p[2].toInt()) else null
}
s.length == 8 -> Triple(s.substring(0, 4).toInt().takeIf { it > 0 }, s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
else -> null
}
} catch (e: Exception) {
null
}
}

View File

@@ -164,6 +164,20 @@ class SettingsStore @Inject constructor(
get() = prefs.getBoolean(K_HIDE_MENU, false) get() = prefs.getBoolean(K_HIDE_MENU, false)
set(value) = prefs.edit().putBoolean(K_HIDE_MENU, value).apply() set(value) = prefs.edit().putBoolean(K_HIDE_MENU, value).apply()
/** Whether this device mirrors its Contacts birthdays into the birthday calendar. */
var birthdaysSyncEnabled: Boolean
get() = prefs.getBoolean(K_BDAY_ENABLED, false)
set(value) = prefs.edit().putBoolean(K_BDAY_ENABLED, value).apply()
/** Stable per-install id used to scope this device's contact-birthday rows. */
val birthdaysDeviceId: String
get() {
prefs.getString(K_BDAY_DEVICE, null)?.let { return it }
val id = java.util.UUID.randomUUID().toString()
prefs.edit().putString(K_BDAY_DEVICE, id).apply()
return id
}
// --- Hidden calendars ("source:id") --- // --- Hidden calendars ("source:id") ---
var hiddenCalendarKeys: Set<String> var hiddenCalendarKeys: Set<String>
@@ -219,6 +233,8 @@ class SettingsStore @Inject constructor(
const val K_CACHE_MONTHS = "cache_months" const val K_CACHE_MONTHS = "cache_months"
const val K_MONTH_PAGED = "month_view_paged" const val K_MONTH_PAGED = "month_view_paged"
const val K_HIDE_MENU = "hide_menu_button" const val K_HIDE_MENU = "hide_menu_button"
const val K_BDAY_ENABLED = "birthdays_sync_enabled"
const val K_BDAY_DEVICE = "birthdays_device_id"
const val K_HIDDEN = "hidden_calendar_keys" const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys" const val K_BANISHED = "banished_calendar_keys"
const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys" const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys"

View File

@@ -222,6 +222,12 @@ interface CalendarrApi {
@DELETE("api/local/events/{uid}") @DELETE("api/local/events/{uid}")
suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody> suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody>
@GET("api/local/calendars/{id}/birthdays")
suspend fun getBirthdays(@Path("id") id: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry>
@POST("api/birthdays/sync-report")
suspend fun reportBirthdaySync(@Body body: RequestBody): Response<ResponseBody>
@POST("api/caldav/events") @POST("api/caldav/events")
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody> suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>

View File

@@ -101,6 +101,18 @@ data class UserProfile(
@Json(name = "directory_hidden") val directoryHidden: Boolean = false, @Json(name = "directory_hidden") val directoryHidden: Boolean = false,
) )
/** One stored birthday (GET /api/local/calendars/{id}/birthdays). Contact-sourced
* rows carry an external_uid ("contact:<deviceId>:<contactId>"); manual ones null. */
@JsonClass(generateAdapter = false)
data class BirthdayEntry(
val uid: String,
@Json(name = "external_uid") val externalUid: String? = null,
val title: String = "",
val month: Int? = null,
val day: Int? = null,
@Json(name = "birth_year") val birthYear: Int? = null,
)
/** A calendar the user can create events in (resolved from all writable sources). */ /** A calendar the user can create events in (resolved from all writable sources). */
data class WritableCalendar( data class WritableCalendar(
val id: String, val id: String,

View File

@@ -158,6 +158,7 @@ object L10n {
"birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen", "birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen",
"birthday.calendar_name" to "Geburtstage", "birthday.calendar_name" to "Geburtstage",
"birthday.activate" to "Geburtstagskalender aktivieren", "birthday.activate" to "Geburtstagskalender aktivieren",
"birthday.import_contacts" to "Aus Kontakten importieren",
"birthday.activate_hint" to "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.", "birthday.activate_hint" to "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
"birthday.is_calendar" to "Geburtstagskalender", "birthday.is_calendar" to "Geburtstagskalender",
"birthday.person" to "Name", "birthday.person_ph" to "Name der Person", "birthday.person" to "Name", "birthday.person_ph" to "Name der Person",
@@ -337,6 +338,7 @@ object L10n {
"birthday.new" to "New birthday", "birthday.new_title" to "Add new birthday", "birthday.new" to "New birthday", "birthday.new_title" to "Add new birthday",
"birthday.calendar_name" to "Birthdays", "birthday.calendar_name" to "Birthdays",
"birthday.activate" to "Enable birthday calendar", "birthday.activate" to "Enable birthday calendar",
"birthday.import_contacts" to "Import from contacts",
"birthday.activate_hint" to "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.", "birthday.activate_hint" to "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
"birthday.is_calendar" to "Birthday calendar", "birthday.is_calendar" to "Birthday calendar",
"birthday.person" to "Name", "birthday.person_ph" to "Person's name", "birthday.person" to "Name", "birthday.person_ph" to "Person's name",

View File

@@ -40,6 +40,7 @@ fun BirthdayDialog(
calendar: LocalCalendar?, calendar: LocalCalendar?,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onActivate: () -> Unit, onActivate: () -> Unit,
onImportContacts: () -> Unit,
onSave: (name: String, date: LocalDate, yearKnown: Boolean) -> Unit, onSave: (name: String, date: LocalDate, yearKnown: Boolean) -> Unit,
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
@@ -70,6 +71,8 @@ fun BirthdayDialog(
selected = yearUnknown, onClick = { yearUnknown = !yearUnknown }, selected = yearUnknown, onClick = { yearUnknown = !yearUnknown },
label = { Text(tr("birthday.year_unknown")) }, label = { Text(tr("birthday.year_unknown")) },
) )
Spacer(Modifier.size(4.dp))
TextButton(onClick = onImportContacts) { Text(tr("birthday.import_contacts")) }
} }
} }
}, },

View File

@@ -130,6 +130,30 @@ fun CalendarScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
} }
// Contacts birthday import (mirrors iOS): request permission on demand; sync
// this device's contact birthdays into the birthday calendar.
val deviceName = android.os.Build.MODEL ?: "Android"
fun runBirthdayImport() {
vm.birthdaysSyncEnabled = true
vm.syncContactBirthdays(com.scarriffle.calendarr.data.ContactsReader.readBirthdays(context), deviceName)
}
val contactsPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { granted -> if (granted) runBirthdayImport() }
fun startBirthdayImport() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.READ_CONTACTS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) runBirthdayImport() else contactsPermLauncher.launch(android.Manifest.permission.READ_CONTACTS)
}
androidx.compose.runtime.LaunchedEffect(Unit) {
if (vm.birthdaysSyncEnabled &&
androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.READ_CONTACTS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) runBirthdayImport()
}
var viewMenuOpen by remember { mutableStateOf(false) } var viewMenuOpen by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var detailEvent by remember { mutableStateOf<CalEvent?>(null) } var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
@@ -304,6 +328,7 @@ fun CalendarScreen(
calendar = birthdayCal, calendar = birthdayCal,
onDismiss = { showBirthday = false }, onDismiss = { showBirthday = false },
onActivate = { birthdayScope.launch { birthdayCal = vm.ensureBirthdayCalendar(birthdayCalName) } }, onActivate = { birthdayScope.launch { birthdayCal = vm.ensureBirthdayCalendar(birthdayCalName) } },
onImportContacts = { showBirthday = false; startBirthdayImport() },
onSave = { name, date, yearKnown -> onSave = { name, date, yearKnown ->
birthdayCal?.let { vm.createBirthday(it.id, name, date, yearKnown) {} } birthdayCal?.let { vm.createBirthday(it.id, name, date, yearKnown) {} }
showBirthday = false showBirthday = false

View File

@@ -145,6 +145,64 @@ class CalendarViewModel @Inject constructor(
val calendarOrder: List<String> get() = settingsStore.calendarOrder val calendarOrder: List<String> get() = settingsStore.calendarOrder
fun setCalendarOrder(keys: List<String>) { settingsStore.calendarOrder = keys } fun setCalendarOrder(keys: List<String>) { settingsStore.calendarOrder = keys }
// ---- Contacts birthday import (mirrors the iOS BirthdaysImporter) ----
var birthdaysSyncEnabled: Boolean
get() = settingsStore.birthdaysSyncEnabled
set(value) { settingsStore.birthdaysSyncEnabled = value }
/** Mirror this device's contact birthdays into the (existing) birthday
* calendar: reconcile rows scoped to this device's external_uid prefix
* (add / update / delete), then report the device. No-op if disabled or
* the birthday calendar hasn't been created. */
fun syncContactBirthdays(contacts: List<com.scarriffle.calendarr.data.ContactBirthday>, deviceName: String) {
if (!settingsStore.birthdaysSyncEnabled) return
viewModelScope.launch {
runCatching {
val cal = repository.getLocalCalendars().firstOrNull { it.isBirthday && it.owned }
?: return@runCatching
val existing = repository.getBirthdayEntries(cal.id)
val deviceId = settingsStore.birthdaysDeviceId
val prefix = "contact:$deviceId:"
val byExt = existing.filter { it.externalUid?.startsWith(prefix) == true }
.associateBy { it.externalUid!! }
val seen = mutableSetOf<String>()
for (c in contacts) {
val ext = prefix + c.contactId
seen.add(ext)
val (start, end) = birthdayRange(c.month, c.day, c.year)
val match = byExt[ext]
if (match != null) {
val changed = match.title != c.name || match.month != c.month ||
match.day != c.day || match.birthYear != c.year
if (changed) repository.updateLocalEvent(
uid = match.uid, title = c.name, start = start, end = end,
isAllDay = true, location = "", description = "", color = null,
rrule = "FREQ=YEARLY", birthYear = c.year, externalUid = ext,
)
} else {
repository.createLocalEvent(
calendarId = cal.id, title = c.name, start = start, end = end,
isAllDay = true, location = "", description = "", color = null,
rrule = "FREQ=YEARLY", birthYear = c.year, externalUid = ext,
)
}
}
byExt.filterKeys { it !in seen }.values.forEach { repository.deleteLocalEvent(it.uid) }
repository.reportBirthdaySync(deviceId, deviceName, contacts.size)
}
loadVisible(force = true)
}
}
private fun birthdayRange(month: Int, day: Int, year: Int?): Pair<Instant, Instant> {
val anchor = year ?: 2000 // leap-safe anchor for Feb 29 with unknown year
val date = LocalDate.of(anchor, month, day)
val start = date.atTime(12, 0).atZone(zone).toInstant()
val end = date.plusDays(1).atTime(12, 0).atZone(zone).toInstant()
return start to end
}
/** Default duration (minutes) for a new event's end time. */ /** Default duration (minutes) for a new event's end time. */
val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes