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