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:
@@ -355,9 +355,9 @@ class CalendarRepository @Inject constructor(
|
||||
calendarId: Int, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||
rrule: String? = null, birthYear: Int? = null,
|
||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||
) = 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()
|
||||
}
|
||||
|
||||
@@ -365,8 +365,9 @@ class CalendarRepository @Inject constructor(
|
||||
uid: String, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||
) = 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()
|
||||
}
|
||||
|
||||
@@ -618,7 +619,7 @@ class CalendarRepository @Inject constructor(
|
||||
calendarId: Int?, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||
rrule: String? = null, birthYear: Int? = null,
|
||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||
) = jsonBody(
|
||||
buildMap {
|
||||
calendarId?.let { put("calendar_id", it) }
|
||||
@@ -633,6 +634,17 @@ class CalendarRepository @Inject constructor(
|
||||
if (reminders != null) put("reminders", org.json.JSONArray(reminders))
|
||||
if (!rrule.isNullOrBlank()) put("rrule", rrule)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,20 @@ class SettingsStore @Inject constructor(
|
||||
get() = prefs.getBoolean(K_HIDE_MENU, false)
|
||||
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") ---
|
||||
|
||||
var hiddenCalendarKeys: Set<String>
|
||||
@@ -219,6 +233,8 @@ class SettingsStore @Inject constructor(
|
||||
const val K_CACHE_MONTHS = "cache_months"
|
||||
const val K_MONTH_PAGED = "month_view_paged"
|
||||
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_BANISHED = "banished_calendar_keys"
|
||||
const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys"
|
||||
|
||||
@@ -222,6 +222,12 @@ interface CalendarrApi {
|
||||
@DELETE("api/local/events/{uid}")
|
||||
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")
|
||||
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>
|
||||
|
||||
|
||||
@@ -101,6 +101,18 @@ data class UserProfile(
|
||||
@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). */
|
||||
data class WritableCalendar(
|
||||
val id: String,
|
||||
|
||||
@@ -158,6 +158,7 @@ object L10n {
|
||||
"birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen",
|
||||
"birthday.calendar_name" to "Geburtstage",
|
||||
"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.is_calendar" to "Geburtstagskalender",
|
||||
"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.calendar_name" to "Birthdays",
|
||||
"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.is_calendar" to "Birthday calendar",
|
||||
"birthday.person" to "Name", "birthday.person_ph" to "Person's name",
|
||||
|
||||
@@ -40,6 +40,7 @@ fun BirthdayDialog(
|
||||
calendar: LocalCalendar?,
|
||||
onDismiss: () -> Unit,
|
||||
onActivate: () -> Unit,
|
||||
onImportContacts: () -> Unit,
|
||||
onSave: (name: String, date: LocalDate, yearKnown: Boolean) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
@@ -70,6 +71,8 @@ fun BirthdayDialog(
|
||||
selected = yearUnknown, onClick = { yearUnknown = !yearUnknown },
|
||||
label = { Text(tr("birthday.year_unknown")) },
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
TextButton(onClick = onImportContacts) { Text(tr("birthday.import_contacts")) }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -130,6 +130,30 @@ fun CalendarScreen(
|
||||
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 showMenu by remember { mutableStateOf(false) }
|
||||
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
|
||||
@@ -304,6 +328,7 @@ fun CalendarScreen(
|
||||
calendar = birthdayCal,
|
||||
onDismiss = { showBirthday = false },
|
||||
onActivate = { birthdayScope.launch { birthdayCal = vm.ensureBirthdayCalendar(birthdayCalName) } },
|
||||
onImportContacts = { showBirthday = false; startBirthdayImport() },
|
||||
onSave = { name, date, yearKnown ->
|
||||
birthdayCal?.let { vm.createBirthday(it.id, name, date, yearKnown) {} }
|
||||
showBirthday = false
|
||||
|
||||
@@ -145,6 +145,64 @@ class CalendarViewModel @Inject constructor(
|
||||
val calendarOrder: List<String> get() = settingsStore.calendarOrder
|
||||
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. */
|
||||
val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user