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.
- Immutable. Every operation returns a new value. Nothing you hold can change underneath you.
- Thread-safe. Values, the pattern table and the formatter cache are all safe to share.
- Testable.
now(),fromNow()andisToday()all accept ajava.time.Clock. - Predictable. Parsing is locale-independent and rejects impossible dates; formatting keeps the value's own zone.
- Small. One transitive dependency:
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.
- Text with an offset or zone (
...T08:00:00Z) identifies an exact instant; thezoneIdargument only chooses the zone the result is viewed in. - Text without one is a wall-clock reading and is interpreted 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:
- The untyped
<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 throwIllegalArgumentExceptionon an unsupported type, so existingcatchclauses keep working. parseacceptsIntthrough a deprecated overload. PreferLong: as epoch milliseconds anIntcannot address anything past 25 January 1970.fromNowreturnsRelativeTimeinstead ofPair<Long, FromNowLocalizeUnit>. It destructures the same way, and.first/.second/.toPair()are provided, so only an explicitPairtype annotation breaks.- Ambiguous 12-hour shorthands without an AM/PM marker (
hM,hMs,dMyHms,sqlYMdHm,sqlYMdHms) are deprecated in favour of the…24or…Avariants. Behaviour unchanged.
Genuinely breaking, with no shim:
- Chained calls no longer mutate in place — assign the result:
val later = date.plus(days = 1). Code that calleddate.plus(days = 1)for its side effect and then re-readdatewill now see the original value. This is the one change worth grepping for. formatrenders in the value's own zone rather than defaulting toLOCAL, so atoUTC().format(…)that silently came back as local time now stays UTC.- The
@TestOnlysetters (setTestGetter,setTestManipulator,setTestZonedDateTime) are gone. Inject ajava.time.Clockinstead — 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 whatbuildSrcused 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.