release: v2.13.0 — special dates, custom fonts, quick-switch, es/it translations #58
@@ -5,6 +5,15 @@
|
|||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!--
|
||||||
|
Optional and feature-gated: only the "Contact special dates" feature reads
|
||||||
|
contacts, and only after the user enables it and grants this at runtime
|
||||||
|
(never requested at startup). Everything stays offline — birthdays and
|
||||||
|
other contact dates are mirrored one-way into local calendars; contacts
|
||||||
|
are never written and nothing leaves the device (the app has no INTERNET
|
||||||
|
permission). See docs/design/contact-special-dates.md.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
<!--
|
<!--
|
||||||
Lets the "Reliable delivery" setting open the direct system dialog to
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||||
|
* device's contacts. Read-only and offline — the one-way source for the
|
||||||
|
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||||
|
* the permission is absent so the sync can degrade to a stalled state rather
|
||||||
|
* than crash.
|
||||||
|
*/
|
||||||
|
interface ContactSpecialDatesDataSource {
|
||||||
|
fun hasPermission(): Boolean
|
||||||
|
|
||||||
|
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||||
|
fun readSpecialDates(): List<ContactSpecialDate>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||||
|
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||||
|
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||||
|
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||||
|
else -> SpecialDateType.Custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ContactSpecialDatesDataSource {
|
||||||
|
|
||||||
|
override fun hasPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||||
|
if (!hasPermission()) return emptyList()
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
// A contact can carry the same date more than once (multiple raw
|
||||||
|
// contacts under one aggregate); keep the first per (contact, type).
|
||||||
|
val seen = HashSet<Pair<String, SpecialDateType>>()
|
||||||
|
val result = ArrayList<ContactSpecialDate>()
|
||||||
|
runCatching {
|
||||||
|
resolver.query(
|
||||||
|
ContactsContract.Data.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||||
|
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||||
|
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||||
|
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||||
|
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||||
|
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||||
|
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||||
|
if (!seen.add(lookup to type)) continue
|
||||||
|
result += ContactSpecialDate(
|
||||||
|
lookupKey = lookup,
|
||||||
|
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||||
|
type = type,
|
||||||
|
month = parts.month,
|
||||||
|
day = parts.day,
|
||||||
|
year = parts.year,
|
||||||
|
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ContactSpecialDates"
|
||||||
|
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
ContactsContract.Data.LOOKUP_KEY,
|
||||||
|
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||||
|
Event.START_DATE,
|
||||||
|
Event.TYPE,
|
||||||
|
Event.LABEL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,6 +47,12 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindReminderAlertStore(
|
abstract fun bindReminderAlertStore(
|
||||||
impl: AndroidReminderAlertStore,
|
impl: AndroidReminderAlertStore,
|
||||||
): ReminderAlertStore
|
): ReminderAlertStore
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindContactSpecialDatesDataSource(
|
||||||
|
impl: AndroidContactSpecialDatesDataSource,
|
||||||
|
): ContactSpecialDatesDataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds of contact "special date" Calendula mirrors into local calendars.
|
||||||
|
* Each kind gets its own local calendar, so it inherits per-calendar colour,
|
||||||
|
* visibility and reminder defaults for free. See
|
||||||
|
* docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
enum class SpecialDateType {
|
||||||
|
Birthday,
|
||||||
|
Anniversary,
|
||||||
|
|
||||||
|
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dated event read from a device contact (a `ContactsContract` `Event`
|
||||||
|
* row). The [lookupKey] is the stable contact identity used to reconcile the
|
||||||
|
* mirror without duplicating; [year] is null when the contact stored the date
|
||||||
|
* without one (`--MM-dd`), in which case age can't be shown.
|
||||||
|
*/
|
||||||
|
data class ContactSpecialDate(
|
||||||
|
val lookupKey: String,
|
||||||
|
val displayName: String,
|
||||||
|
val type: SpecialDateType,
|
||||||
|
val month: Int,
|
||||||
|
val day: Int,
|
||||||
|
val year: Int?,
|
||||||
|
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
|
||||||
|
val label: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The parsed month/day (+ optional year) of a contact date. */
|
||||||
|
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fixed leap year to validate and anchor year-less dates against, so that a
|
||||||
|
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
|
||||||
|
* years — matching how the calendar provider expands a yearly Feb-29 series).
|
||||||
|
*/
|
||||||
|
const val YEARLESS_ANCHOR_YEAR = 1972
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
|
||||||
|
* calendar parts, or null if it isn't a usable date. Handles the three shapes
|
||||||
|
* seen in the wild:
|
||||||
|
* - full `yyyy-MM-dd` (year known),
|
||||||
|
* - year-less `--MM-dd` (year null),
|
||||||
|
* - compact `yyyyMMdd`.
|
||||||
|
*
|
||||||
|
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
|
||||||
|
* it's unit-tested without a device.
|
||||||
|
*/
|
||||||
|
fun parseContactEventDate(raw: String?): ContactDateParts? {
|
||||||
|
val s = raw?.trim().orEmpty()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
|
||||||
|
// Year-less: "--MM-dd" or "--MMdd".
|
||||||
|
if (s.startsWith("--")) {
|
||||||
|
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
|
||||||
|
return validated(null, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.contains('-')) {
|
||||||
|
val parts = s.split('-').filter { it.isNotEmpty() }
|
||||||
|
return when (parts.size) {
|
||||||
|
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
|
||||||
|
// first part, so require the first token to look like a year.
|
||||||
|
3 -> if (s.startsWith('-')) null else validated(
|
||||||
|
year = parts[0].toIntOrNull() ?: return null,
|
||||||
|
month = parts[1].toIntOrNull() ?: return null,
|
||||||
|
day = parts[2].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
// Year-less "MM-dd" without the "--" prefix.
|
||||||
|
2 -> validated(
|
||||||
|
year = null,
|
||||||
|
month = parts[0].toIntOrNull() ?: return null,
|
||||||
|
day = parts[1].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact "yyyyMMdd".
|
||||||
|
if (s.length == 8 && s.all { it.isDigit() }) {
|
||||||
|
return validated(
|
||||||
|
year = s.substring(0, 4).toInt(),
|
||||||
|
month = s.substring(4, 6).toInt(),
|
||||||
|
day = s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "MM-dd" or compact "MMdd". */
|
||||||
|
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
|
||||||
|
if (rest.contains('-')) {
|
||||||
|
val p = rest.split('-').filter { it.isNotEmpty() }
|
||||||
|
if (p.size != 2) return null
|
||||||
|
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
|
||||||
|
}
|
||||||
|
if (rest.length == 4 && rest.all { it.isDigit() }) {
|
||||||
|
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm month/day form a real calendar date (validated against the actual
|
||||||
|
* year when known, otherwise the leap anchor so `02-29` survives).
|
||||||
|
*/
|
||||||
|
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
|
||||||
|
if (month !in 1..12 || day !in 1..31) return null
|
||||||
|
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
|
||||||
|
return runCatching { LocalDate.of(checkYear, month, day) }
|
||||||
|
.map { ContactDateParts(year, month, day) }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ContactSpecialDateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a full yyyy-MM-dd date with year`() {
|
||||||
|
val parts = parseContactEventDate("1990-05-14")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a year-less --MM-dd date`() {
|
||||||
|
val parts = parseContactEventDate("--05-14")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a compact yyyyMMdd date`() {
|
||||||
|
val parts = parseContactEventDate("19900514")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a year-less MM-dd date without the double-dash prefix`() {
|
||||||
|
val parts = parseContactEventDate("12-25")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps a year-less Feb 29 by validating against a leap anchor`() {
|
||||||
|
val parts = parseContactEventDate("--02-29")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps Feb 29 in a real leap year`() {
|
||||||
|
assertThat(parseContactEventDate("2000-02-29"))
|
||||||
|
.isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects Feb 29 in a non-leap year`() {
|
||||||
|
assertThat(parseContactEventDate("1999-02-29")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects an impossible month or day`() {
|
||||||
|
assertThat(parseContactEventDate("2020-13-01")).isNull()
|
||||||
|
assertThat(parseContactEventDate("2020-06-31")).isNull()
|
||||||
|
assertThat(parseContactEventDate("--00-10")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects blank, null and garbage input`() {
|
||||||
|
assertThat(parseContactEventDate(null)).isNull()
|
||||||
|
assertThat(parseContactEventDate("")).isNull()
|
||||||
|
assertThat(parseContactEventDate(" ")).isNull()
|
||||||
|
assertThat(parseContactEventDate("not-a-date")).isNull()
|
||||||
|
assertThat(parseContactEventDate("2020")).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user