
Date/time parsing, formatting, arithmetic and humanized relative-times with immutable, thread‑safe API; auto-detect parsing, type‑safe formatting/timezone chains, boundary navigation, field setters, live Compose helpers.
An immutable, thread-safe date and time library for Android and Kotlin/JVM — parsing, manipulation, querying and formatting, with no dependencies beyond the Kotlin standard library.
now(), fromNow() and isToday() all accept a java.time.Clock.kotlin-stdlib.val start = DateCed.parse("2023-10-01")
val end = start.plus(days = 30) // `start` is untouched
end.dMy // 31 Oct 2023
end.isAfter(start) // true
end.fromNow().let { "${it.amount} ${it.unit} ${it.direction}" }Step 1. Make sure Maven Central is in your repositories, in settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Step 2. Add the dependency:
dependencies {
implementation("io.github.kamrul3288:dateced:3.0.0")
}Upgrading from 1.1.1? The group id changed from
com.github.kamrul3288(JitPack) toio.github.kamrul3288(Maven Central), so replace the whole line rather than bumping the version. Once nothing else needs it, you can drop the JitPack repository.
A note on 2.x. Versions
2.0.0–2.2.0were a Kotlin Multiplatform rewrite that has been withdrawn; they remain on Maven Central only because releases there are immutable. 3.0.0 continues directly from 1.1.1 and is the version to use. Because it shares the 2.x group id and sorts above it, dependency-update tools and Gradle's highest-wins conflict resolution will both settle on 3.0.0.
Step 3 — required if your minSdk is below 26. Dateced exposes java.time types, which
only exist natively from API 26. Enable core library desugaring in the consuming module:
android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}Requires minSdk 21 or higher and Java 17 bytecode.
A DateCed is a value, not a builder. Operations return new instances, so a single value
can be shared across threads, cached in a field, or held in a val without defensive copies:
val deadline = DateCed.parse("2023-12-31T23:59:59Z", zoneId = TimeZoneId.UTC)
// Safe from any thread, concurrently.
launch(Dispatchers.IO) { log(deadline.iso) }
launch(Dispatchers.Default) { check(deadline.isAfter(DateCed.now())) }Formatters are compiled once and cached in a bounded ConcurrentHashMap, so repeated
formatting is cheap as well as safe.
Upgrading from 1.x? In 1.x,
DateCed.Factoryreturned one shared mutable instance, so two call sites — on one thread or several — silently overwrote each other's date. See the migration guide below.
DateCed.now() // device zone
DateCed.now(TimeZoneId.UTC)
DateCed.parse("2023-10-01") // format inferred
DateCed.parse("2023-10-01T08:18:59.123+06:00") // offset honoured
DateCed.parse("01 Oct 2023 5:30 PM")
DateCed.parse("05:30 PM") // time only: today's date
DateCed.parse("01.10.2023", pattern = "dd.MM.yyyy")
DateCed.parse(1_696_147_534_242L) // epoch milliseconds
DateCed.parseOrNull(untrustedInput) // null instead of throwing
DateCed.of(someZonedDateTime) // wrap java.timeFormats recognised without a pattern:
| Shape | Examples |
|---|---|
| ISO-8601 |
2023-10-01, 2023-10-01T08:18:59, 2023-10-01 08:18:59, ...T08:18:59.123456789Z, ...+06:00, ...+06:00[Asia/Dhaka]
|
| Day first |
01-10-2023, 01/10/2023, plus HH:mm[:ss] or h:mm[:ss] AM/PM
|
| Month name |
01 Oct 2023, 1 oct 2023 5:30 pm
|
| Time only |
20:30, 20:30:15, 05:30 PM
|
Parsing is pinned to English (Locale.US), so a device's language never decides whether a
string parses. Month names and AM/PM are matched case-insensitively. Impossible
dates are rejected rather than shifted: parse("2023-02-30") throws
DateCedParseException, it does not become 28 February.
...T08:00:00Z) identifies an exact instant; the zoneId
argument only chooses the zone the result is viewed in.zoneId.// 08:18:59+06:00 is 02:18:59 UTC.
DateCed.parse("2023-10-01T08:18:59+06:00", zoneId = TimeZoneId.UTC).hour // 2
// No offset: 08:18:59 is taken as UTC wall time.
DateCed.parse("2023-10-01 08:18:59", zoneId = TimeZoneId.UTC).hour // 8date.format("dd MMM uuuu") // arbitrary pattern
date.format("HH:mm", zoneId = TimeZoneId.UTC) // render in another zone
date.format("EEEE", locale = Locale.US) // pin the localeformat renders in the value's own zone unless you pass zoneId, so toUTC().hM24
really does show UTC.
Shorthands, grouped by what they contain:
| Numeric only (locale-independent) | Result |
|---|---|
d, y
|
01, 2023
|
h24, hM24, hMs24
|
20, 20:30, 20:30:45
|
sqlYMd, sqlYMd24Hm, sqlYMd24Hms
|
2023-10-01, 2023-10-01 20:30, 2023-10-01 20:30:45
|
iso, isoInstant
|
2023-10-01T20:30:45.123+06:00, 2023-10-01T14:30:45.123Z
|
| Contains names (default locale) | Result |
|---|---|
day, m
|
Sunday, Oct
|
dMy, dM
|
01 Oct 2023, 01 Oct
|
dMyHmA, dMyHmsA
|
01 Oct 2023 08:30 PM, 01 Oct 2023 08:30:45 PM
|
dMyHm24, dMyHms24
|
01 Oct 2023 20:30, 01 Oct 2023 20:30:45
|
hMa, hMsA
|
08:30 PM, 08:30:45 PM
|
hmADmY, hmsADmY, hm24DmY, hms24DmY
|
08:30 PM 01 Oct 2023, … |
iso and isoInstant round-trip through DateCed.parse.
date.year; date.month; date.monthValue
date.dayOfYear; date.dayOfMonth; date.dayOfWeek
date.hour; date.minute; date.second; date.millisecond; date.nanosecond
date.zone; date.isLeapYear; date.isWeekend; date.isWeekday
date.toEpochMilliseconds()
date.toInstant(); date.toLocalDate(); date.toLocalTime(); date.toLocalDateTime()
date.zonedDateTime // the underlying java.time value
DateCed.isLeapYear(2024) // trueEvery method returns a new value.
date.plus(days = 3, hours = 4)
date.minus(months = 1, weeks = 2)
date.plus(Duration.ofMinutes(90))
date.toLocal(); date.toUTC(); date.toGMT()
date.withZone(ZoneId.of("Asia/Dhaka")) // same instant, new view
date.startOfDay(); date.endOfDay()
date.startOfMonth(); date.endOfMonth()
date.startOfYear(); date.endOfYear()Calendar units are applied largest first (years, months, weeks, days) and clock units
afterwards, so plus(months = 1, days = 1) reads the way you'd say it. Adding a month
clamps the day: 31 January plus one month is 28 (or 29) February.
Zone conversion preserves the instant exactly, including nanoseconds.
val (amount, unit, direction) = date.fromNow()
// e.g. 3, DAYS, PAST
date.fromNow(FromNowUnit.HOUR) // force a unit
date.fromNow(clock = fixedClock) // deterministic in testsamount is never negative — read direction (PAST, PRESENT, FUTURE). unit is
singular only when the amount is exactly 1, which makes it safe to map straight onto
plural-aware string resources.
FromNowUnit.DEFAULT picks the largest unit that still reads naturally: seconds, minutes,
hours, days, weeks, months, then years. Month and year thresholds come from the calendar,
not from a 30-day approximation.
date.timeDifference(other, TimeDifferenceUnit.DAY) // absolute
date.timeDifference("2023-11-01", unit = TimeDifferenceUnit.DAY)
date.until(other, TimeDifferenceUnit.MONTH) // signedUnits: MILLISECOND, SECOND, MINUTES, HOUR, DAY, WEEK, MONTH, YEAR.
MONTH and YEAR count whole calendar units; the rest come from the exact elapsed
duration, with no loss of sub-second precision.
date.isBefore(other); date.isAfter(other)
date.isEqualOrBefore(other); date.isEqualOrAfter(other)
date.isEqual(other) // same instant, zone ignored
date.isBetween(start, end) // exclusive; bounds in either order
date.isEqualOrBetween(start, end) // inclusive
date.isToday(); date.isYesterday(); date.isTomorrow()
date.isSameDay(other)
date.isInDaysOfWeek(listOf(DayOfWeek.FRIDAY, DayOfWeek.SATURDAY))Each comparison also accepts a String (with optional pattern and timeZoneId) or epoch
milliseconds, so date.isBefore("2023-10-10") works without parsing first.
DateCed implements Comparable, so operators and ranges work directly:
if (start < end) { … }
if (date in start..end) { … }
listOf(c, a, b).sorted()== requires the instant and the zone to match; isEqual compares instants only.
Anything that reads the clock takes one, so tests never depend on when they run:
private val clock = Clock.fixed(Instant.parse("2024-02-29T12:00:00Z"), ZoneOffset.UTC)
@Test
fun `expiry is reported correctly`() {
val expiry = DateCed.parse("2024-03-01", zoneId = TimeZoneId.UTC, clock = clock)
assertTrue(expiry.isTomorrow(clock))
assertEquals(1L, expiry.fromNow(clock = clock).amount)
}All failures extend DateCedException, which extends IllegalArgumentException:
| Exception | Cause |
|---|---|
DateCedParseException |
Input is blank, unrecognised, not a real date, or does not match the supplied pattern. |
DateCedPatternException |
The format pattern itself is invalid. |
Use DateCed.parseOrNull where input is untrusted.
3.0 succeeds 1.1.1 directly. The behaviour changes matter far more than the API changes: almost every 1.x call site still compiles, but several now return a different — correct — answer.
| Area | 1.x | 3.0 |
|---|---|---|
| Shared state |
Factory returned one mutable singleton; concurrent or interleaved use returned another caller's date |
Every call returns an independent immutable value |
isEqual |
Delegated to isEqualOrAfter, so any earlier date returned true
|
Compares instants |
millisecond() |
Returned second * 1000
|
millisecond returns millisecond-of-second |
fromNow pluralisation |
Singular for > 1, plural for 0
|
Singular only for exactly 1
|
fromNow direction |
Absolute value; past and future indistinguishable | RelativeTime.direction |
fromNow 30–31 day gaps |
Could report 0 months
|
Reports weeks, then months |
toUTC/toLocal/toGMT
|
Round-tripped through seconds, dropping milliseconds | Instant preserved exactly |
MILLISECOND difference |
seconds * 1000 |
Exact milliseconds |
| Formatting zone | Defaulted to LOCAL, undoing toUTC()
|
Renders in the value's own zone |
| Parsing locale | Default locale, so output could stop being re-parseable | Always English (Locale.US) |
| ISO offsets |
Z treated as a literal; +06:00 unsupported |
Offsets honoured; +06:00 and bracketed zones parse |
| Invalid dates |
2023-02-30 silently became 28 February |
Rejected |
| Time-only input | Threw | Parses against today's date |
// Entry points — Factory still works, deprecated.
DateCed.Factory.now() -> DateCed.now()
DateCed.Factory.parse("…") -> DateCed.parse("…")
// Getters are properties now. The functions remain, deprecated.
date.year() -> date.year
date.dayOfWeek() -> date.dayOfWeek
date.toMillisecond() -> date.toEpochMilliseconds()
date.dateTime() -> date.zonedDateTime
// Renamed
date.isisLeapYear(2024) -> DateCed.isLeapYear(2024) // or date.isLeapYear
date.isTodayBetweenDaysOfWeek(d) -> date.isInDaysOfWeek(d) // now tests the valueStill compiling, deprecated:
<T> comparisons (isBefore, isEqualOrBefore, isAfter, isEqualOrAfter,
isEqual, isBetween, isEqualOrBetween, timeDifference) are replaced by typed
overloads. Any-accepting versions remain for the arguments the typed API does not model —
Int, ZonedDateTime — and for calls that use the 1.x parameter names
(secondDateTime, thirdDateTime). They still throw IllegalArgumentException on an
unsupported type, so existing catch clauses keep working.parse accepts Int through a deprecated overload. Prefer Long: as epoch milliseconds
an Int cannot address anything past 25 January 1970.fromNow returns RelativeTime instead of Pair<Long, FromNowLocalizeUnit>. It
destructures the same way, and .first / .second / .toPair() are provided, so only an
explicit Pair type annotation breaks.hM, hMs, dMyHms, sqlYMdHm,
sqlYMdHms) are deprecated in favour of the …24 or …A variants. Behaviour unchanged.Genuinely breaking, with no shim:
val later = date.plus(days = 1). Code that called date.plus(days = 1) for its side
effect and then re-read date will now see the original value. This is the one change
worth grepping for.format renders in the value's own zone rather than defaulting to LOCAL, so a
toUTC().format(…) that silently came back as local time now stays UTC.@TestOnly setters (setTestGetter, setTestManipulator, setTestZonedDateTime) are
gone. Inject a java.time.Clock instead — see Testing time-dependent code../gradlew build # compile, test and lint everything
./gradlew :dateced:testDebugUnitTest # library tests only
./gradlew :dateced:publishToMavenLocal # AAR + sources + javadoc to ~/.m2
./gradlew :sample:installDebug # sample app on a device
./gradlew publishToMavenCentral # upload, then release manually in the portal
./gradlew publishAndReleaseToMavenCentral # upload and release in one step
| Tool | Version |
|---|---|
| Gradle | 9.6.1 |
| Android Gradle Plugin | 9.3.1 |
| Kotlin | 2.4.10 |
| JDK | 17 |
| compileSdk / targetSdk | 37 |
| minSdk | 21 (library), 23 (sample) |
Layout:
gradle/libs.versions.toml — the single source of truth for every version, including SDK levels.build-logic/ — an included build holding the convention plugins (dateced.android.library,
dateced.android.application, dateced.maven.publish). Editing one does not invalidate the
whole project, which is what buildSrc used to do.dateced/ — the published library.sample/ — an app that exercises the full API and prints the results.Kotlin compilation comes from AGP's built-in support, so org.jetbrains.kotlin.android is
never applied to a module; it is declared in the root build only to pin the Kotlin version.
To cut a release, change VERSION_NAME in gradle.properties, tag the commit, and run
publishAndReleaseToMavenCentral. Publishing needs four credentials, which are deliberately
absent from the repository — put them in ~/.gradle/gradle.properties, or pass them as
ORG_GRADLE_PROJECT_* environment variables in CI:
mavenCentralUsername=<Central Portal token username>
mavenCentralPassword=<Central Portal token password>
signingInMemoryKey=<armoured GPG secret key, newlines stripped>
signingInMemoryKeyPassword=<key passphrase>Maven Central never lets a released version be replaced, so a version number can be used
exactly once. 2.0.0–2.2.0 are already spent on the withdrawn Kotlin Multiplatform line.
Apache 2.0 — see LICENSE.
An immutable, thread-safe date and time library for Android and Kotlin/JVM — parsing, manipulation, querying and formatting, with no dependencies beyond the Kotlin standard library.
now(), fromNow() and isToday() all accept a java.time.Clock.kotlin-stdlib.val start = DateCed.parse("2023-10-01")
val end = start.plus(days = 30) // `start` is untouched
end.dMy // 31 Oct 2023
end.isAfter(start) // true
end.fromNow().let { "${it.amount} ${it.unit} ${it.direction}" }Step 1. Make sure Maven Central is in your repositories, in settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Step 2. Add the dependency:
dependencies {
implementation("io.github.kamrul3288:dateced:3.0.0")
}Upgrading from 1.1.1? The group id changed from
com.github.kamrul3288(JitPack) toio.github.kamrul3288(Maven Central), so replace the whole line rather than bumping the version. Once nothing else needs it, you can drop the JitPack repository.
A note on 2.x. Versions
2.0.0–2.2.0were a Kotlin Multiplatform rewrite that has been withdrawn; they remain on Maven Central only because releases there are immutable. 3.0.0 continues directly from 1.1.1 and is the version to use. Because it shares the 2.x group id and sorts above it, dependency-update tools and Gradle's highest-wins conflict resolution will both settle on 3.0.0.
Step 3 — required if your minSdk is below 26. Dateced exposes java.time types, which
only exist natively from API 26. Enable core library desugaring in the consuming module:
android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}Requires minSdk 21 or higher and Java 17 bytecode.
A DateCed is a value, not a builder. Operations return new instances, so a single value
can be shared across threads, cached in a field, or held in a val without defensive copies:
val deadline = DateCed.parse("2023-12-31T23:59:59Z", zoneId = TimeZoneId.UTC)
// Safe from any thread, concurrently.
launch(Dispatchers.IO) { log(deadline.iso) }
launch(Dispatchers.Default) { check(deadline.isAfter(DateCed.now())) }Formatters are compiled once and cached in a bounded ConcurrentHashMap, so repeated
formatting is cheap as well as safe.
Upgrading from 1.x? In 1.x,
DateCed.Factoryreturned one shared mutable instance, so two call sites — on one thread or several — silently overwrote each other's date. See the migration guide below.
DateCed.now() // device zone
DateCed.now(TimeZoneId.UTC)
DateCed.parse("2023-10-01") // format inferred
DateCed.parse("2023-10-01T08:18:59.123+06:00") // offset honoured
DateCed.parse("01 Oct 2023 5:30 PM")
DateCed.parse("05:30 PM") // time only: today's date
DateCed.parse("01.10.2023", pattern = "dd.MM.yyyy")
DateCed.parse(1_696_147_534_242L) // epoch milliseconds
DateCed.parseOrNull(untrustedInput) // null instead of throwing
DateCed.of(someZonedDateTime) // wrap java.timeFormats recognised without a pattern:
| Shape | Examples |
|---|---|
| ISO-8601 |
2023-10-01, 2023-10-01T08:18:59, 2023-10-01 08:18:59, ...T08:18:59.123456789Z, ...+06:00, ...+06:00[Asia/Dhaka]
|
| Day first |
01-10-2023, 01/10/2023, plus HH:mm[:ss] or h:mm[:ss] AM/PM
|
| Month name |
01 Oct 2023, 1 oct 2023 5:30 pm
|
| Time only |
20:30, 20:30:15, 05:30 PM
|
Parsing is pinned to English (Locale.US), so a device's language never decides whether a
string parses. Month names and AM/PM are matched case-insensitively. Impossible
dates are rejected rather than shifted: parse("2023-02-30") throws
DateCedParseException, it does not become 28 February.
...T08:00:00Z) identifies an exact instant; the zoneId
argument only chooses the zone the result is viewed in.zoneId.// 08:18:59+06:00 is 02:18:59 UTC.
DateCed.parse("2023-10-01T08:18:59+06:00", zoneId = TimeZoneId.UTC).hour // 2
// No offset: 08:18:59 is taken as UTC wall time.
DateCed.parse("2023-10-01 08:18:59", zoneId = TimeZoneId.UTC).hour // 8date.format("dd MMM uuuu") // arbitrary pattern
date.format("HH:mm", zoneId = TimeZoneId.UTC) // render in another zone
date.format("EEEE", locale = Locale.US) // pin the localeformat renders in the value's own zone unless you pass zoneId, so toUTC().hM24
really does show UTC.
Shorthands, grouped by what they contain:
| Numeric only (locale-independent) | Result |
|---|---|
d, y
|
01, 2023
|
h24, hM24, hMs24
|
20, 20:30, 20:30:45
|
sqlYMd, sqlYMd24Hm, sqlYMd24Hms
|
2023-10-01, 2023-10-01 20:30, 2023-10-01 20:30:45
|
iso, isoInstant
|
2023-10-01T20:30:45.123+06:00, 2023-10-01T14:30:45.123Z
|
| Contains names (default locale) | Result |
|---|---|
day, m
|
Sunday, Oct
|
dMy, dM
|
01 Oct 2023, 01 Oct
|
dMyHmA, dMyHmsA
|
01 Oct 2023 08:30 PM, 01 Oct 2023 08:30:45 PM
|
dMyHm24, dMyHms24
|
01 Oct 2023 20:30, 01 Oct 2023 20:30:45
|
hMa, hMsA
|
08:30 PM, 08:30:45 PM
|
hmADmY, hmsADmY, hm24DmY, hms24DmY
|
08:30 PM 01 Oct 2023, … |
iso and isoInstant round-trip through DateCed.parse.
date.year; date.month; date.monthValue
date.dayOfYear; date.dayOfMonth; date.dayOfWeek
date.hour; date.minute; date.second; date.millisecond; date.nanosecond
date.zone; date.isLeapYear; date.isWeekend; date.isWeekday
date.toEpochMilliseconds()
date.toInstant(); date.toLocalDate(); date.toLocalTime(); date.toLocalDateTime()
date.zonedDateTime // the underlying java.time value
DateCed.isLeapYear(2024) // trueEvery method returns a new value.
date.plus(days = 3, hours = 4)
date.minus(months = 1, weeks = 2)
date.plus(Duration.ofMinutes(90))
date.toLocal(); date.toUTC(); date.toGMT()
date.withZone(ZoneId.of("Asia/Dhaka")) // same instant, new view
date.startOfDay(); date.endOfDay()
date.startOfMonth(); date.endOfMonth()
date.startOfYear(); date.endOfYear()Calendar units are applied largest first (years, months, weeks, days) and clock units
afterwards, so plus(months = 1, days = 1) reads the way you'd say it. Adding a month
clamps the day: 31 January plus one month is 28 (or 29) February.
Zone conversion preserves the instant exactly, including nanoseconds.
val (amount, unit, direction) = date.fromNow()
// e.g. 3, DAYS, PAST
date.fromNow(FromNowUnit.HOUR) // force a unit
date.fromNow(clock = fixedClock) // deterministic in testsamount is never negative — read direction (PAST, PRESENT, FUTURE). unit is
singular only when the amount is exactly 1, which makes it safe to map straight onto
plural-aware string resources.
FromNowUnit.DEFAULT picks the largest unit that still reads naturally: seconds, minutes,
hours, days, weeks, months, then years. Month and year thresholds come from the calendar,
not from a 30-day approximation.
date.timeDifference(other, TimeDifferenceUnit.DAY) // absolute
date.timeDifference("2023-11-01", unit = TimeDifferenceUnit.DAY)
date.until(other, TimeDifferenceUnit.MONTH) // signedUnits: MILLISECOND, SECOND, MINUTES, HOUR, DAY, WEEK, MONTH, YEAR.
MONTH and YEAR count whole calendar units; the rest come from the exact elapsed
duration, with no loss of sub-second precision.
date.isBefore(other); date.isAfter(other)
date.isEqualOrBefore(other); date.isEqualOrAfter(other)
date.isEqual(other) // same instant, zone ignored
date.isBetween(start, end) // exclusive; bounds in either order
date.isEqualOrBetween(start, end) // inclusive
date.isToday(); date.isYesterday(); date.isTomorrow()
date.isSameDay(other)
date.isInDaysOfWeek(listOf(DayOfWeek.FRIDAY, DayOfWeek.SATURDAY))Each comparison also accepts a String (with optional pattern and timeZoneId) or epoch
milliseconds, so date.isBefore("2023-10-10") works without parsing first.
DateCed implements Comparable, so operators and ranges work directly:
if (start < end) { … }
if (date in start..end) { … }
listOf(c, a, b).sorted()== requires the instant and the zone to match; isEqual compares instants only.
Anything that reads the clock takes one, so tests never depend on when they run:
private val clock = Clock.fixed(Instant.parse("2024-02-29T12:00:00Z"), ZoneOffset.UTC)
@Test
fun `expiry is reported correctly`() {
val expiry = DateCed.parse("2024-03-01", zoneId = TimeZoneId.UTC, clock = clock)
assertTrue(expiry.isTomorrow(clock))
assertEquals(1L, expiry.fromNow(clock = clock).amount)
}All failures extend DateCedException, which extends IllegalArgumentException:
| Exception | Cause |
|---|---|
DateCedParseException |
Input is blank, unrecognised, not a real date, or does not match the supplied pattern. |
DateCedPatternException |
The format pattern itself is invalid. |
Use DateCed.parseOrNull where input is untrusted.
3.0 succeeds 1.1.1 directly. The behaviour changes matter far more than the API changes: almost every 1.x call site still compiles, but several now return a different — correct — answer.
| Area | 1.x | 3.0 |
|---|---|---|
| Shared state |
Factory returned one mutable singleton; concurrent or interleaved use returned another caller's date |
Every call returns an independent immutable value |
isEqual |
Delegated to isEqualOrAfter, so any earlier date returned true
|
Compares instants |
millisecond() |
Returned second * 1000
|
millisecond returns millisecond-of-second |
fromNow pluralisation |
Singular for > 1, plural for 0
|
Singular only for exactly 1
|
fromNow direction |
Absolute value; past and future indistinguishable | RelativeTime.direction |
fromNow 30–31 day gaps |
Could report 0 months
|
Reports weeks, then months |
toUTC/toLocal/toGMT
|
Round-tripped through seconds, dropping milliseconds | Instant preserved exactly |
MILLISECOND difference |
seconds * 1000 |
Exact milliseconds |
| Formatting zone | Defaulted to LOCAL, undoing toUTC()
|
Renders in the value's own zone |
| Parsing locale | Default locale, so output could stop being re-parseable | Always English (Locale.US) |
| ISO offsets |
Z treated as a literal; +06:00 unsupported |
Offsets honoured; +06:00 and bracketed zones parse |
| Invalid dates |
2023-02-30 silently became 28 February |
Rejected |
| Time-only input | Threw | Parses against today's date |
// Entry points — Factory still works, deprecated.
DateCed.Factory.now() -> DateCed.now()
DateCed.Factory.parse("…") -> DateCed.parse("…")
// Getters are properties now. The functions remain, deprecated.
date.year() -> date.year
date.dayOfWeek() -> date.dayOfWeek
date.toMillisecond() -> date.toEpochMilliseconds()
date.dateTime() -> date.zonedDateTime
// Renamed
date.isisLeapYear(2024) -> DateCed.isLeapYear(2024) // or date.isLeapYear
date.isTodayBetweenDaysOfWeek(d) -> date.isInDaysOfWeek(d) // now tests the valueStill compiling, deprecated:
<T> comparisons (isBefore, isEqualOrBefore, isAfter, isEqualOrAfter,
isEqual, isBetween, isEqualOrBetween, timeDifference) are replaced by typed
overloads. Any-accepting versions remain for the arguments the typed API does not model —
Int, ZonedDateTime — and for calls that use the 1.x parameter names
(secondDateTime, thirdDateTime). They still throw IllegalArgumentException on an
unsupported type, so existing catch clauses keep working.parse accepts Int through a deprecated overload. Prefer Long: as epoch milliseconds
an Int cannot address anything past 25 January 1970.fromNow returns RelativeTime instead of Pair<Long, FromNowLocalizeUnit>. It
destructures the same way, and .first / .second / .toPair() are provided, so only an
explicit Pair type annotation breaks.hM, hMs, dMyHms, sqlYMdHm,
sqlYMdHms) are deprecated in favour of the …24 or …A variants. Behaviour unchanged.Genuinely breaking, with no shim:
val later = date.plus(days = 1). Code that called date.plus(days = 1) for its side
effect and then re-read date will now see the original value. This is the one change
worth grepping for.format renders in the value's own zone rather than defaulting to LOCAL, so a
toUTC().format(…) that silently came back as local time now stays UTC.@TestOnly setters (setTestGetter, setTestManipulator, setTestZonedDateTime) are
gone. Inject a java.time.Clock instead — see Testing time-dependent code../gradlew build # compile, test and lint everything
./gradlew :dateced:testDebugUnitTest # library tests only
./gradlew :dateced:publishToMavenLocal # AAR + sources + javadoc to ~/.m2
./gradlew :sample:installDebug # sample app on a device
./gradlew publishToMavenCentral # upload, then release manually in the portal
./gradlew publishAndReleaseToMavenCentral # upload and release in one step
| Tool | Version |
|---|---|
| Gradle | 9.6.1 |
| Android Gradle Plugin | 9.3.1 |
| Kotlin | 2.4.10 |
| JDK | 17 |
| compileSdk / targetSdk | 37 |
| minSdk | 21 (library), 23 (sample) |
Layout:
gradle/libs.versions.toml — the single source of truth for every version, including SDK levels.build-logic/ — an included build holding the convention plugins (dateced.android.library,
dateced.android.application, dateced.maven.publish). Editing one does not invalidate the
whole project, which is what buildSrc used to do.dateced/ — the published library.sample/ — an app that exercises the full API and prints the results.Kotlin compilation comes from AGP's built-in support, so org.jetbrains.kotlin.android is
never applied to a module; it is declared in the root build only to pin the Kotlin version.
To cut a release, change VERSION_NAME in gradle.properties, tag the commit, and run
publishAndReleaseToMavenCentral. Publishing needs four credentials, which are deliberately
absent from the repository — put them in ~/.gradle/gradle.properties, or pass them as
ORG_GRADLE_PROJECT_* environment variables in CI:
mavenCentralUsername=<Central Portal token username>
mavenCentralPassword=<Central Portal token password>
signingInMemoryKey=<armoured GPG secret key, newlines stripped>
signingInMemoryKeyPassword=<key passphrase>Maven Central never lets a released version be replaced, so a version number can be used
exactly once. 2.0.0–2.2.0 are already spent on the withdrawn Kotlin Multiplatform line.
Apache 2.0 — see LICENSE.