diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2c0adeb..cd16cb1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
? = 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? = 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? = 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 =
+ 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()
+ }
}
diff --git a/app/src/main/java/com/scarriffle/calendarr/data/ContactsReader.kt b/app/src/main/java/com/scarriffle/calendarr/data/ContactsReader.kt
new file mode 100644
index 0000000..630fe49
--- /dev/null
+++ b/app/src/main/java/com/scarriffle/calendarr/data/ContactsReader.kt
@@ -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 {
+ val out = mutableListOf()
+ 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? = 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
+ }
+}
diff --git a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt
index 5e57788..5ddd6ba 100644
--- a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt
+++ b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt
@@ -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
@@ -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"
diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt
index c39c00d..4620af3 100644
--- a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt
+++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt
@@ -222,6 +222,12 @@ interface CalendarrApi {
@DELETE("api/local/events/{uid}")
suspend fun deleteLocalEvent(@Path("uid") uid: String): Response
+ @GET("api/local/calendars/{id}/birthdays")
+ suspend fun getBirthdays(@Path("id") id: Int): List
+
+ @POST("api/birthdays/sync-report")
+ suspend fun reportBirthdaySync(@Body body: RequestBody): Response
+
@POST("api/caldav/events")
suspend fun createCalDAVEvent(@Body body: RequestBody): Response
diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt
index 68fd569..cd7895b 100644
--- a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt
+++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt
@@ -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::"); 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,
diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt
index 8ad10b0..147f08a 100644
--- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt
+++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt
@@ -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",
diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt
index df89c0d..ea95382 100644
--- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt
+++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt
@@ -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")) }
}
}
},
diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt
index cd419d4..89824c7 100644
--- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt
+++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt
@@ -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(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
diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt
index a455928..f479968 100644
--- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt
+++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt
@@ -145,6 +145,64 @@ class CalendarViewModel @Inject constructor(
val calendarOrder: List get() = settingsStore.calendarOrder
fun setCalendarOrder(keys: List) { 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, 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()
+ 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 {
+ 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