feat(contacts): declare READ_CONTACTS + read contact special-dates

Codeberg #15, foundation for the contact special-dates calendars. Declares the
optional, feature-gated READ_CONTACTS permission (never requested at startup)
and adds the offline, read-only contacts reader.

- AndroidManifest: READ_CONTACTS with a comment documenting the opt-in/offline
  one-way-mirror contract.
- domain/contacts: SpecialDateType + ContactSpecialDate model and a pure
  parseContactEventDate covering full (yyyy-MM-dd), year-less (--MM-dd) and
  compact (yyyyMMdd) shapes, with Feb-29 handling via a leap anchor.
- data/contacts: ContactSpecialDatesDataSource querying ContactsContract.Data
  Event rows, split by TYPE, deduped per (contact, type); returns empty without
  the permission so sync can degrade to a stalled state. Hilt-bound.
- Unit tests for the date parser (full/year-less/compact/Feb-29/malformed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 10:19:23 +02:00
parent a38093cae3
commit 93ca38d974
5 changed files with 301 additions and 0 deletions

View File

@@ -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()
}
}